Resolve all high-severity architecture review findings
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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=<index>` or the durable `nsu=<uri>` 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -342,7 +342,7 @@ public sealed class GrpcPullAuditEventsInvoker
|
||||
KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
|
||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
||||
},
|
||||
}.WithSiteCredentials(_pskProvider, siteId));
|
||||
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId));
|
||||
|
||||
/// <summary>Disposes all cached channels.</summary>
|
||||
public void Dispose()
|
||||
|
||||
@@ -356,7 +356,7 @@ public sealed class GrpcPullSiteCallsInvoker
|
||||
KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
|
||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
||||
},
|
||||
}.WithSiteCredentials(_pskProvider, siteId));
|
||||
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId));
|
||||
|
||||
/// <summary>Disposes all cached channels.</summary>
|
||||
public void Dispose()
|
||||
|
||||
@@ -209,7 +209,7 @@ public sealed class GrpcPullTraceEventsInvoker : GrpcPullTraceEventsClient.IPull
|
||||
KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
|
||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
||||
},
|
||||
}.WithSiteCredentials(_pskProvider, siteId));
|
||||
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId));
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
|
||||
@@ -260,6 +260,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
}
|
||||
|
||||
var batch = new CachedTelemetryBatch();
|
||||
var wireBytes = AuditWireBudget.InitialBytes;
|
||||
var emittedEventIds = new List<Guid>(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<AuditEvent> 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>Records real browser input only after authenticating and validating antiforgery.</summary>
|
||||
public static async Task<IResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="AuthenticationProperties"/> for the login sign-in.
|
||||
/// Deliberately does <b>not</b> set <see cref="AuthenticationProperties.ExpiresUtc"/>.
|
||||
|
||||
@@ -12,6 +12,20 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
/// </summary>
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
/// <summary>Gets fresh mutation authority, failing closed on a mapping error.</summary>
|
||||
public static Task<AuthenticationState> GetAuthoritativeStateAsync(this AuthenticationStateProvider provider)
|
||||
=> provider is CookieAuthenticationStateProvider cookie
|
||||
? cookie.GetAuthoritativeStateAsync() : provider.GetAuthenticationStateAsync();
|
||||
|
||||
/// <summary>Checks the current grant before invoking a directly audited application service.</summary>
|
||||
public static async Task<string> 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();
|
||||
}
|
||||
|
||||
/// <summary>Fallback returned when no username claim is present.</summary>
|
||||
public const string UnknownUser = "unknown";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges ASP.NET Core cookie authentication with Blazor Server's auth state.
|
||||
/// <para>
|
||||
/// The cookie middleware validates and decrypts the cookie during the initial
|
||||
/// HTTP request that establishes the Blazor circuit. This provider is registered
|
||||
/// <c>Scoped</c>, so it is constructed within that request's DI scope while
|
||||
/// <see cref="IHttpContextAccessor.HttpContext"/> is still valid. We snapshot
|
||||
/// the authenticated principal <b>once</b> in the constructor and serve that
|
||||
/// snapshot for the lifetime of the circuit.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// We must NOT read <see cref="IHttpContextAccessor"/> on every
|
||||
/// <see cref="GetAuthenticationStateAsync"/> call: for the
|
||||
/// lifetime of a long-lived SignalR circuit <c>HttpContext</c> is <c>null</c>
|
||||
/// (or, worse, a stale/foreign context), so a later re-evaluation —
|
||||
/// e.g. <c><AuthorizeView></c> re-rendering — would otherwise see an
|
||||
/// unauthenticated principal and render the wrong UI.
|
||||
/// </para>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
|
||||
{
|
||||
private readonly Task<AuthenticationState> _circuitAuthState;
|
||||
private AuthenticationState _state;
|
||||
private readonly IServiceScopeFactory? _scopes;
|
||||
private readonly bool _autoLogin;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshots the authenticated principal from the current HTTP context for use throughout the circuit lifetime.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">Accessor used to read the initial HTTP context principal.</param>
|
||||
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
|
||||
/// <summary>Captures identity and the factory used for subsequent server-side revalidation.</summary>
|
||||
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor, IServiceScopeFactory? scopes = null, IOptions<AuthDisableLoginOptions>? 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
=> _circuitAuthState;
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync() => RefreshAsync(failClosed: false);
|
||||
|
||||
/// <summary>Requires a current mapping before a mutation; mapping faults deny rather than reuse old grants.</summary>
|
||||
public Task<AuthenticationState> GetAuthoritativeStateAsync() => RefreshAsync(failClosed: true);
|
||||
|
||||
private async Task<AuthenticationState> 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<CookieSessionValidator>();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
public sealed class SiteScopeService
|
||||
{
|
||||
private readonly AuthenticationStateProvider _authStateProvider;
|
||||
private (bool IsSystemWide, IReadOnlySet<int> Sites)? _cached;
|
||||
|
||||
/// <summary>Initializes a new instance of <see cref="SiteScopeService"/>.</summary>
|
||||
/// <param name="authStateProvider">The Blazor authentication state provider used to read the current user's claims.</param>
|
||||
@@ -92,10 +91,7 @@ public sealed class SiteScopeService
|
||||
|
||||
private async Task<(bool IsSystemWide, IReadOnlySet<int> 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<int>());
|
||||
|
||||
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<int>)ids);
|
||||
_cached = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
|
||||
|
||||
private List<Site> _sites = new();
|
||||
private Dictionary<int, List<DataConnection>> _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();
|
||||
|
||||
+27
-53
@@ -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<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
private async Task<string> 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<ConfigurationCommandService>().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<string>();
|
||||
foreach (var (attrName, value) in toSave)
|
||||
{
|
||||
var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
|
||||
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().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<string>();
|
||||
foreach (var (attrName, value) in outcome.Overrides)
|
||||
{
|
||||
var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
|
||||
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().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<ConfigurationCommandService>().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<ConfigurationCommandService>().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<ConfigurationCommandService>().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 @@
|
||||
/// </summary>
|
||||
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<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.AssignToAreaAsync(Id, _reassignAreaId == 0 ? null : _reassignAreaId, currentUser));
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? "";
|
||||
|
||||
+6
-4
@@ -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<ConfigurationCommandService>().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<ConfigurationCommandService>().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<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
|
||||
|
||||
/// <summary>
|
||||
/// Opens the folder-tree template picker. A pick clears any dangling-id hint and
|
||||
|
||||
@@ -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<Area>(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<Area>(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<Area>(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<Instance>(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<bool>(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<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
private async Task<string?> 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<ITemplateEngineRepository>();
|
||||
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<ZB.MOM.WW.ScadaBridge.Commons.Types.Result<T>> ExecuteConfigurationAsync<T>(object command)
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = await MutationServices.ExecuteConfigurationAsync(command);
|
||||
return ZB.MOM.WW.ScadaBridge.Commons.Types.Result<T>.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<T>.Failure(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+6
-20
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+6
-23
@@ -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; }
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
|
||||
@@ -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<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
|
||||
|
||||
private List<SharedScript> _scripts = new();
|
||||
private bool _loading = true;
|
||||
|
||||
@@ -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<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
|
||||
}
|
||||
|
||||
@@ -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<string> 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();
|
||||
}
|
||||
|
||||
@@ -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<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
|
||||
|
||||
private List<Template> _templates = new();
|
||||
private List<TemplateFolder> _folders = new();
|
||||
|
||||
+1
-1
@@ -382,7 +382,7 @@ public partial class TransportExport : ComponentBase
|
||||
_downloadError = null;
|
||||
try
|
||||
{
|
||||
var user = await Auth.GetCurrentUsernameAsync();
|
||||
var user = await Auth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
|
||||
var sourceEnv = TransportOptions.Value.SourceEnvironment;
|
||||
if (string.IsNullOrWhiteSpace(sourceEnv))
|
||||
{
|
||||
|
||||
+2
-2
@@ -465,7 +465,7 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await Auth.GetCurrentUsernameAsync();
|
||||
var user = await Auth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
|
||||
var entityName = _session?.Manifest.SourceEnvironment ?? "<unknown>";
|
||||
await AuditService.LogAsync(
|
||||
user: user,
|
||||
@@ -857,7 +857,7 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
_result = null;
|
||||
try
|
||||
{
|
||||
var user = await Auth.GetCurrentUsernameAsync();
|
||||
var user = await Auth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
|
||||
// Fold the operator's Map-step choices into the name map. For
|
||||
// central-config-only bundles (no required mappings) this is
|
||||
// BundleNameMap.Empty, which the importer normalises away.
|
||||
|
||||
+16
-2
@@ -1,3 +1,5 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
|
||||
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
|
||||
@page "/monitoring/parked-messages"
|
||||
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
|
||||
@@ -647,7 +649,10 @@
|
||||
try
|
||||
{
|
||||
var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow);
|
||||
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
|
||||
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
|
||||
var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId);
|
||||
if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked.");
|
||||
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
|
||||
if (resp.Success) success++; else failed++;
|
||||
}
|
||||
catch { failed++; }
|
||||
@@ -680,7 +685,10 @@
|
||||
try
|
||||
{
|
||||
var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow);
|
||||
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
|
||||
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
|
||||
var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId);
|
||||
if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked.");
|
||||
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
|
||||
if (resp.Success) success++; else failed++;
|
||||
}
|
||||
catch { failed++; }
|
||||
@@ -702,6 +710,9 @@
|
||||
try
|
||||
{
|
||||
var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow);
|
||||
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
|
||||
var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId);
|
||||
if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked.");
|
||||
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
|
||||
if (resp.Success)
|
||||
{
|
||||
@@ -731,6 +742,9 @@
|
||||
try
|
||||
{
|
||||
var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow);
|
||||
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
|
||||
var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId);
|
||||
if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked.");
|
||||
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
|
||||
if (resp.Success)
|
||||
{
|
||||
|
||||
+8
-19
@@ -1,7 +1,9 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
||||
@inject IServiceProvider MutationServices
|
||||
@page "/notifications/lists/create"
|
||||
@page "/notifications/lists/{Id:int}/edit"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||
@@ -185,20 +187,9 @@
|
||||
|
||||
try
|
||||
{
|
||||
if (_existing != null)
|
||||
{
|
||||
_existing.Name = _name.Trim();
|
||||
await NotificationRepository.UpdateNotificationListAsync(_existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Type is set at create time and never changes thereafter (the selector
|
||||
// is disabled on edit) so a list's recipients always carry the matching
|
||||
// contact field.
|
||||
var nl = new NotificationList(_name.Trim()) { Type = _type };
|
||||
await NotificationRepository.AddNotificationListAsync(nl);
|
||||
}
|
||||
await NotificationRepository.SaveChangesAsync();
|
||||
await MutationServices.ExecuteConfigurationAsync(_existing is not null
|
||||
? new RenameNotificationListCommand(_existing.Id, _name.Trim())
|
||||
: new CreateNotificationListCommand(_name.Trim(), [], _type));
|
||||
NavigationManager.NavigateTo("/notifications/lists");
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
@@ -238,8 +229,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
await NotificationRepository.AddRecipientAsync(r);
|
||||
await NotificationRepository.SaveChangesAsync();
|
||||
await MutationServices.ExecuteConfigurationAsync(new AddNotificationRecipientCommand(r.NotificationListId, r.Name, r.EmailAddress, r.PhoneNumber));
|
||||
_recipientName = _recipientEmail = _recipientPhone = string.Empty;
|
||||
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
|
||||
}
|
||||
@@ -264,8 +254,7 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
await NotificationRepository.DeleteRecipientAsync(r.Id);
|
||||
await NotificationRepository.SaveChangesAsync();
|
||||
await MutationServices.ExecuteConfigurationAsync(new DeleteNotificationRecipientCommand(r.Id));
|
||||
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id!.Value)).ToList();
|
||||
}
|
||||
catch (Exception ex) { _recipientFormError = ex.Message; }
|
||||
|
||||
+4
-2
@@ -1,3 +1,6 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
||||
@inject IServiceProvider MutationServices
|
||||
@page "/notifications/lists"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
|
||||
@@ -165,8 +168,7 @@
|
||||
}
|
||||
try
|
||||
{
|
||||
await NotificationRepository.DeleteNotificationListAsync(list.Id);
|
||||
await NotificationRepository.SaveChangesAsync();
|
||||
await MutationServices.ExecuteConfigurationAsync(new DeleteNotificationListCommand(list.Id));
|
||||
_toast.ShowSuccess("Deleted.");
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
+9
-4
@@ -604,7 +604,8 @@
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
|
||||
if (!await IsRowSiteAllowedAsync(n.SourceSiteId)) throw new UnauthorizedAccessException("Site access was revoked.");
|
||||
var requestedBy = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
|
||||
var response = await CommunicationService.RetryNotificationAsync(
|
||||
new RetryNotificationRequest(Guid.NewGuid().ToString("N"), n.NotificationId, requestedBy));
|
||||
if (response.Success)
|
||||
@@ -642,7 +643,8 @@
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
|
||||
if (!await IsRowSiteAllowedAsync(n.SourceSiteId)) throw new UnauthorizedAccessException("Site access was revoked.");
|
||||
var requestedBy = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
|
||||
var response = await CommunicationService.DiscardNotificationAsync(
|
||||
new DiscardNotificationRequest(Guid.NewGuid().ToString("N"), n.NotificationId, requestedBy));
|
||||
if (response.Success)
|
||||
@@ -848,6 +850,9 @@
|
||||
return _permittedSiteIds.Contains(resolved.Id);
|
||||
}
|
||||
|
||||
private Task<bool> IsRowSiteAllowedAsync(string sourceSiteId)
|
||||
=> Task.FromResult(IsRowSiteAllowedSync(sourceSiteId));
|
||||
private async Task<bool> IsRowSiteAllowedAsync(string sourceSiteId)
|
||||
{
|
||||
var resolved = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == sourceSiteId);
|
||||
return resolved is not null && await SiteScope.IsSiteAllowedAsync(resolved.Id);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-40
@@ -1,3 +1,6 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
||||
@inject IServiceProvider MutationServices
|
||||
@page "/notifications/sms"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@@ -245,48 +248,18 @@
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingSms != null)
|
||||
if (_editingSms is null && typedAuthToken is null)
|
||||
{
|
||||
_editingSms.AccountSid = _accountSid.Trim();
|
||||
_editingSms.FromNumber = string.IsNullOrWhiteSpace(_fromNumber) ? null : _fromNumber.Trim();
|
||||
_editingSms.MessagingServiceSid = string.IsNullOrWhiteSpace(_messagingServiceSid)
|
||||
? null
|
||||
: _messagingServiceSid.Trim();
|
||||
_editingSms.ApiBaseUrl = string.IsNullOrWhiteSpace(_apiBaseUrl) ? null : _apiBaseUrl.Trim();
|
||||
// Preserve-if-blank: only overwrite the stored token when a new value was typed.
|
||||
if (typedAuthToken != null)
|
||||
{
|
||||
_editingSms.AuthToken = typedAuthToken;
|
||||
}
|
||||
_editingSms.ConnectionTimeoutSeconds = _connectionTimeoutSeconds;
|
||||
_editingSms.MaxRetries = _maxRetries;
|
||||
_editingSms.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
|
||||
await NotificationRepository.UpdateSmsConfigurationAsync(_editingSms);
|
||||
_formError = "Auth Token is required.";
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (typedAuthToken == null)
|
||||
{
|
||||
_formError = "Auth Token is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
var sms = new SmsConfigurationEntity(
|
||||
_accountSid.Trim(),
|
||||
string.IsNullOrWhiteSpace(_fromNumber) ? null : _fromNumber.Trim())
|
||||
{
|
||||
MessagingServiceSid = string.IsNullOrWhiteSpace(_messagingServiceSid)
|
||||
? null
|
||||
: _messagingServiceSid.Trim(),
|
||||
ApiBaseUrl = string.IsNullOrWhiteSpace(_apiBaseUrl) ? null : _apiBaseUrl.Trim(),
|
||||
AuthToken = typedAuthToken,
|
||||
ConnectionTimeoutSeconds = _connectionTimeoutSeconds,
|
||||
MaxRetries = _maxRetries,
|
||||
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds),
|
||||
};
|
||||
await NotificationRepository.AddSmsConfigurationAsync(sms);
|
||||
}
|
||||
await NotificationRepository.SaveChangesAsync();
|
||||
var from = string.IsNullOrWhiteSpace(_fromNumber) ? null : _fromNumber.Trim();
|
||||
var messaging = string.IsNullOrWhiteSpace(_messagingServiceSid) ? null : _messagingServiceSid.Trim();
|
||||
await MutationServices.ExecuteConfigurationAsync(_editingSms is not null
|
||||
? new UpdateSmsConfigCommand(_editingSms.Id, _accountSid.Trim(), from, messaging, _apiBaseUrl?.Trim() ?? "", typedAuthToken,
|
||||
_connectionTimeoutSeconds, _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds))
|
||||
: new CreateSmsConfigCommand(_accountSid.Trim(), from, messaging, _apiBaseUrl?.Trim(), typedAuthToken,
|
||||
_connectionTimeoutSeconds, _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds)));
|
||||
_showForm = false;
|
||||
_toast.ShowSuccess("SMS configuration saved.");
|
||||
await LoadAsync();
|
||||
|
||||
+8
-27
@@ -1,3 +1,6 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
||||
@inject IServiceProvider MutationServices
|
||||
@page "/notifications/smtp"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@@ -345,33 +348,11 @@
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingSmtp != null)
|
||||
{
|
||||
_editingSmtp.Host = _host.Trim();
|
||||
_editingSmtp.Port = _port;
|
||||
_editingSmtp.Transport = _transport;
|
||||
_editingSmtp.AuthType = _authType;
|
||||
_editingSmtp.TlsMode = _tlsMode;
|
||||
_editingSmtp.Credentials = _credentials?.Trim();
|
||||
_editingSmtp.FromAddress = _fromAddress.Trim();
|
||||
_editingSmtp.OAuth2Authority = NormalizeOptional(_oauth2Authority);
|
||||
_editingSmtp.OAuth2Scope = NormalizeOptional(_oauth2Scope);
|
||||
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
|
||||
}
|
||||
else
|
||||
{
|
||||
var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim())
|
||||
{
|
||||
Port = _port,
|
||||
Transport = _transport,
|
||||
TlsMode = _tlsMode,
|
||||
Credentials = _credentials?.Trim(),
|
||||
OAuth2Authority = NormalizeOptional(_oauth2Authority),
|
||||
OAuth2Scope = NormalizeOptional(_oauth2Scope)
|
||||
};
|
||||
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
|
||||
}
|
||||
await NotificationRepository.SaveChangesAsync();
|
||||
await MutationServices.ExecuteConfigurationAsync(_editingSmtp is not null
|
||||
? new UpdateSmtpConfigCommand(_editingSmtp.Id, _host.Trim(), _port, _authType, _fromAddress.Trim(), _tlsMode,
|
||||
_credentials?.Trim() ?? "", NormalizeOptional(_oauth2Authority) ?? "", NormalizeOptional(_oauth2Scope) ?? "", _transport)
|
||||
: new CreateSmtpConfigCommand(_host.Trim(), _port, _authType, _fromAddress.Trim(), _tlsMode,
|
||||
_credentials?.Trim(), NormalizeOptional(_oauth2Authority), NormalizeOptional(_oauth2Scope), _transport));
|
||||
_showForm = false;
|
||||
_toast.ShowSuccess("SMTP configuration saved.");
|
||||
await LoadAsync();
|
||||
|
||||
+7
-11
@@ -338,7 +338,8 @@ public partial class SiteCallsReport
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
|
||||
if (!await IsRowSiteAllowedAsync(c.SourceSite)) throw new UnauthorizedAccessException("Site access was revoked.");
|
||||
var requestedBy = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
|
||||
var response = await CommunicationService.RetrySiteCallAsync(
|
||||
new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite, requestedBy));
|
||||
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
|
||||
@@ -374,7 +375,8 @@ public partial class SiteCallsReport
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
|
||||
if (!await IsRowSiteAllowedAsync(c.SourceSite)) throw new UnauthorizedAccessException("Site access was revoked.");
|
||||
var requestedBy = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
|
||||
var response = await CommunicationService.DiscardSiteCallAsync(
|
||||
new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite, requestedBy));
|
||||
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
|
||||
@@ -589,16 +591,10 @@ public partial class SiteCallsReport
|
||||
/// Server-side re-check for the Retry/Discard relay. True for a system-wide
|
||||
/// user, or when the row's source site maps to a Site.Id in the permitted set.
|
||||
/// </summary>
|
||||
private Task<bool> IsRowSiteAllowedAsync(string sourceSite)
|
||||
private async Task<bool> IsRowSiteAllowedAsync(string sourceSite)
|
||||
{
|
||||
if (_siteScopeSystemWide)
|
||||
return Task.FromResult(true);
|
||||
|
||||
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSite);
|
||||
if (resolved is null)
|
||||
return Task.FromResult(true);
|
||||
|
||||
return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
|
||||
var resolved = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == sourceSite);
|
||||
return resolved is not null && await SiteScope.IsSiteAllowedAsync(resolved.Id);
|
||||
}
|
||||
|
||||
// ── Trends ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,28 +2,11 @@
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<AntiforgeryToken />
|
||||
|
||||
@code {
|
||||
// CentralUI-005 / CentralUI-020: session expiry is a sliding window owned by
|
||||
// the cookie authentication middleware (ZB.MOM.WW.ScadaBridge.Security AddCookie:
|
||||
// ExpireTimeSpan = idle timeout, SlidingExpiration = true). An active user's
|
||||
// cookie is continually renewed; an idle user's cookie lapses after the idle
|
||||
// timeout. There is no fixed login-time deadline to redirect at.
|
||||
//
|
||||
// This component must NOT poll the Blazor AuthenticationStateProvider:
|
||||
// CookieAuthenticationStateProvider serves a frozen constructor-time
|
||||
// principal for the whole circuit (CentralUI-004), so the polled auth state
|
||||
// can never transition to "expired" and the redirect would never fire
|
||||
// (CentralUI-020).
|
||||
//
|
||||
// Instead it polls the server endpoint GET /auth/ping via fetch(). Being a
|
||||
// normal HTTP request, the cookie middleware re-validates — and slides — the
|
||||
// cookie on every hit, and answers 200 while the session is live or 401 once
|
||||
// it has lapsed. A genuine idle user's circuit produces no other HTTP
|
||||
// traffic, so once the cookie lapses the next ping returns 401 and this
|
||||
// component redirects to /login. (The ping itself slides the cookie, but the
|
||||
// poll interval is well under the idle timeout, so an idle session still
|
||||
// lapses on schedule once the poll catches the lapsed state — the ping only
|
||||
// ever observes expiry, it does not keep a dead session alive.)
|
||||
// Passive HTTP probes observe expiry but never advance activity. The JS module
|
||||
// separately reports trusted browser input through an antiforgery-protected POST.
|
||||
|
||||
/// <summary>Server endpoint that reports live session validity.</summary>
|
||||
internal const string PingUrl = "/auth/ping";
|
||||
@@ -49,6 +32,14 @@
|
||||
_ = PollSessionAsync(_cts.Token);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || IsOnLoginPage) return;
|
||||
try { _module ??= await JS.InvokeAsync<IJSObjectReference>("import", ModulePath); }
|
||||
catch (JSDisconnectedException) { }
|
||||
catch (JSException) { }
|
||||
}
|
||||
|
||||
private bool IsOnLoginPage =>
|
||||
Navigation.ToBaseRelativePath(Navigation.Uri)
|
||||
.StartsWith("login", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -37,6 +37,7 @@ public static class ServiceCollectionExtensions
|
||||
// Resolves the current user's permitted site set from their SiteId claims
|
||||
// so Deployment/Monitoring pages can enforce site scoping.
|
||||
services.AddScoped<SiteScopeService>();
|
||||
services.AddScoped<ConfigurationCommandService>();
|
||||
|
||||
// Centralised dialog service: pages inject IDialogService and a single
|
||||
// <DialogHost /> in MainLayout renders the active dialog. See
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
@@ -39,7 +40,7 @@ public sealed class BindingTester : IBindingTester
|
||||
// roles, so the Designer check must happen here before any cross-cluster
|
||||
// traffic. Use HasClaim against JwtTokenService.RoleClaimType (not
|
||||
// IsInRole, per c1e16cf).
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
var state = await _auth.GetAuthoritativeStateAsync();
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
|
||||
{
|
||||
return new ReadTagValuesResult(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
@@ -45,7 +46,7 @@ public sealed class BrowseService : IBrowseService
|
||||
{
|
||||
// CentralUI-side role guard — sites don't enforce envelope-level roles,
|
||||
// so the Designer check must happen here before any cross-cluster traffic.
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
var state = await _auth.GetAuthoritativeStateAsync();
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
|
||||
{
|
||||
return new BrowseNodeResult(
|
||||
@@ -100,7 +101,7 @@ public sealed class BrowseService : IBrowseService
|
||||
// Same CentralUI-side role guard as BrowseChildrenAsync — sites don't
|
||||
// enforce envelope-level roles, so the Designer check must happen here
|
||||
// before any cross-cluster traffic.
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
var state = await _auth.GetAuthoritativeStateAsync();
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
|
||||
{
|
||||
return new SearchAddressSpaceResult(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
@@ -142,7 +143,7 @@ public sealed class CertManagementService : ICertManagementService
|
||||
|
||||
private async Task<bool> HasRoleAsync(string role)
|
||||
{
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
var state = await _auth.GetAuthoritativeStateAsync();
|
||||
return state.User.HasClaim(JwtTokenService.RoleClaimType, role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>Fresh caller authorization and audited configuration commands for page event handlers.</summary>
|
||||
public sealed class ConfigurationCommandService(IServiceProvider services, AuthenticationStateProvider auth)
|
||||
{
|
||||
/// <summary>Runs an existing audited instance operation with fresh scope and an atomic audit commit.</summary>
|
||||
public async Task<T> ExecuteInstanceAsync<T>(int? instanceId, int? createSiteId,
|
||||
Func<ZB.MOM.WW.ScadaBridge.TemplateEngine.Services.InstanceService, string, Task<T>> operation)
|
||||
{
|
||||
var username = await auth.RequireRoleAsync(Roles.Deployer);
|
||||
var sites = new SiteScopeService(auth);
|
||||
try
|
||||
{
|
||||
return (T)(await services.GetRequiredService<IConfigurationCommandTransaction>().ExecuteAsync(async sp =>
|
||||
{
|
||||
var siteId = createSiteId;
|
||||
if (instanceId.HasValue)
|
||||
{
|
||||
var instance = await sp.GetRequiredService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ITemplateEngineRepository>().GetInstanceByIdAsync(instanceId.Value);
|
||||
siteId = instance?.SiteId;
|
||||
}
|
||||
if (!siteId.HasValue || !await sites.IsSiteAllowedAsync(siteId.Value))
|
||||
throw new UnauthorizedAccessException("Instance site access was revoked.");
|
||||
return await operation(sp.GetRequiredService<ZB.MOM.WW.ScadaBridge.TemplateEngine.Services.InstanceService>(), username);
|
||||
}))!;
|
||||
}
|
||||
finally { services.GetService<IConfigurationReadSession>()?.Reset(); }
|
||||
}
|
||||
|
||||
/// <summary>Executes one command through the same guarded boundary as the management actor.</summary>
|
||||
public async Task<object?> ExecuteAsync(object command)
|
||||
{
|
||||
var principal = (await auth.GetAuthoritativeStateAsync()).User;
|
||||
if (principal.Identity?.IsAuthenticated != true) throw new UnauthorizedAccessException("Session expired.");
|
||||
var user = new AuthenticatedUser(principal.GetUsername(), principal.GetDisplayName() ?? principal.GetUsername(),
|
||||
principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToArray(),
|
||||
principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToArray());
|
||||
try { return await ManagementActor.ExecuteConfigurationCommandAsync(services, command, user, services.GetService<CircuitClientAddressProvider>()?.ClientAddress); }
|
||||
finally { services.GetService<IConfigurationReadSession>()?.Reset(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resolves the mutation facade only when a page acts, keeping read-only rendering independent.</summary>
|
||||
public static class ConfigurationCommandExtensions
|
||||
{
|
||||
/// <summary>Submits a database-only configuration mutation with current server-side authority.</summary>
|
||||
public static Task<object?> ExecuteConfigurationAsync(this IServiceProvider services, object command)
|
||||
=> services.GetRequiredService<ConfigurationCommandService>().ExecuteAsync(command);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using System.Security.Claims;
|
||||
using Akka.Actor;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
@@ -106,7 +107,7 @@ public sealed class DataConnectionMoveService : IDataConnectionMoveService
|
||||
/// </summary>
|
||||
private async Task<AuthenticatedUser> BuildAuthenticatedUserAsync()
|
||||
{
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
var state = await _auth.GetAuthoritativeStateAsync();
|
||||
var principal = state.User;
|
||||
|
||||
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
|
||||
@@ -47,7 +48,7 @@ public sealed class EndpointVerificationService : IEndpointVerificationService
|
||||
{
|
||||
// CentralUI-side role guard — sites don't enforce envelope-level roles, so the
|
||||
// Designer check must happen here before any cross-cluster traffic.
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
var state = await _auth.GetAuthoritativeStateAsync();
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
|
||||
{
|
||||
return new VerifyEndpointResult(
|
||||
|
||||
@@ -299,7 +299,7 @@ public sealed class FlowDebugService : IFlowDebugService
|
||||
var shortId = c.ToString("N")[..12];
|
||||
try
|
||||
{
|
||||
var requestedBy = await _auth.GetCurrentUsernameAsync();
|
||||
var requestedBy = await _auth.RequireRoleAsync(Roles.Deployer);
|
||||
var corr = Guid.NewGuid().ToString("N");
|
||||
|
||||
if (isNotify)
|
||||
@@ -580,7 +580,7 @@ public sealed class FlowDebugService : IFlowDebugService
|
||||
|
||||
private async Task<AuthenticatedUser> BuildAuthenticatedUserAsync()
|
||||
{
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
var state = await _auth.GetAuthoritativeStateAsync();
|
||||
var principal = state.User;
|
||||
|
||||
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
@@ -144,7 +145,7 @@ public sealed class SchemaLibraryService : ISchemaLibraryService
|
||||
/// </summary>
|
||||
private async Task<AuthenticatedUser> BuildAuthenticatedUserAsync()
|
||||
{
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
var state = await _auth.GetAuthoritativeStateAsync();
|
||||
var principal = state.User;
|
||||
|
||||
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
@@ -165,7 +166,7 @@ public sealed class SecuredWriteService : ISecuredWriteService
|
||||
/// </summary>
|
||||
private async Task<AuthenticatedUser> BuildAuthenticatedUserAsync()
|
||||
{
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
var state = await _auth.GetAuthoritativeStateAsync();
|
||||
var principal = state.User;
|
||||
|
||||
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
@@ -112,7 +113,7 @@ public sealed class TemplateInheritanceQueryService : ITemplateInheritanceQueryS
|
||||
/// </summary>
|
||||
private async Task<AuthenticatedUser> BuildAuthenticatedUserAsync()
|
||||
{
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
var state = await _auth.GetAuthoritativeStateAsync();
|
||||
var principal = state.User;
|
||||
|
||||
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown";
|
||||
|
||||
@@ -21,3 +21,24 @@ export async function ping(url) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// SignalR event traffic and automatic pings are not user activity. Only trusted
|
||||
// browser input can schedule this antiforgery-protected request.
|
||||
let lastReported = 0;
|
||||
let reporting = false;
|
||||
async function recordInput(event) {
|
||||
if (!event.isTrusted || reporting || Date.now() - lastReported < 15000) return;
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
if (!token) return;
|
||||
reporting = true;
|
||||
try {
|
||||
const response = await fetch('/auth/activity', {
|
||||
method: 'POST', credentials: 'same-origin', cache: 'no-store',
|
||||
headers: { 'RequestVerificationToken': token }
|
||||
});
|
||||
if (response.ok) lastReported = Date.now();
|
||||
} catch { /* A failed request never advances the server's idle clock. */ }
|
||||
finally { reporting = false; }
|
||||
}
|
||||
for (const name of ['pointerdown', 'keydown', 'wheel', 'touchstart'])
|
||||
document.addEventListener(name, recordInput, { passive: true });
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
/// <summary>Executes one database-only configuration command and its audit rows atomically.</summary>
|
||||
public interface IConfigurationCommandTransaction
|
||||
{
|
||||
/// <summary>Each database retry supplies a fresh scope; the operation must not perform external side effects.</summary>
|
||||
Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation);
|
||||
}
|
||||
|
||||
/// <summary>Defers local cache/channel maintenance until a configuration commit succeeds.</summary>
|
||||
public interface IConfigurationCommitActions
|
||||
{
|
||||
/// <summary>Queues an idempotent local maintenance action.</summary>
|
||||
void Add(Func<Task> action);
|
||||
}
|
||||
|
||||
/// <summary>Evicts a circuit's tracked read entities after a separately scoped configuration command.</summary>
|
||||
public interface IConfigurationReadSession
|
||||
{
|
||||
/// <summary>Clears stale tracked read state; form drafts must be held separately.</summary>
|
||||
void Reset();
|
||||
}
|
||||
+2
-2
@@ -2,9 +2,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
public record ListDatabaseConnectionsCommand;
|
||||
public record GetDatabaseConnectionCommand(int DatabaseConnectionId);
|
||||
public record CreateDatabaseConnectionDefCommand(string Name, string ConnectionString);
|
||||
public record CreateDatabaseConnectionDefCommand(string Name, string ConnectionString, int? MaxRetries = null, TimeSpan? RetryDelay = null);
|
||||
// ConnectionString is nullable (arch-review C3): an omitted (null) value preserves
|
||||
// the stored secret rather than wiping it — the connection string is elided from
|
||||
// List/Get responses, so a round-trip must not require re-sending it.
|
||||
public record UpdateDatabaseConnectionDefCommand(int DatabaseConnectionId, string Name, string? ConnectionString);
|
||||
public record UpdateDatabaseConnectionDefCommand(int DatabaseConnectionId, string Name, string? ConnectionString, int? MaxRetries = null, TimeSpan? RetryDelay = null);
|
||||
public record DeleteDatabaseConnectionDefCommand(int DatabaseConnectionId);
|
||||
|
||||
@@ -85,3 +85,6 @@ public record NativeAlarmSourceOverrideEntry(
|
||||
public record SetInstanceNativeAlarmSourceOverridesCommand(
|
||||
int InstanceId,
|
||||
IReadOnlyList<NativeAlarmSourceOverrideEntry> Overrides);
|
||||
|
||||
/// <summary>Removes an attribute override so the inherited value is used again.</summary>
|
||||
public record DeleteInstanceAttributeOverrideCommand(int InstanceId, string AttributeName);
|
||||
|
||||
@@ -14,4 +14,17 @@ public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port,
|
||||
public record ListSmsConfigsCommand;
|
||||
// FromNumber is optional: a Twilio Messaging-Service-only config supplies MessagingServiceSid
|
||||
// instead. At-least-one-of (FromNumber, MessagingServiceSid) is validated at the CLI/UI boundary.
|
||||
public record UpdateSmsConfigCommand(int SmsConfigId, string AccountSid, string? FromNumber, string? MessagingServiceSid = null, string? ApiBaseUrl = null, string? AuthToken = null);
|
||||
public record UpdateSmsConfigCommand(int SmsConfigId, string AccountSid, string? FromNumber, string? MessagingServiceSid = null, string? ApiBaseUrl = null, string? AuthToken = null, int? ConnectionTimeoutSeconds = null, int? MaxRetries = null, TimeSpan? RetryDelay = null);
|
||||
|
||||
/// <summary>Renames a list without replacing its named recipients.</summary>
|
||||
public record RenameNotificationListCommand(int NotificationListId, string Name);
|
||||
/// <summary>Adds a named recipient matching the owning list's channel.</summary>
|
||||
public record AddNotificationRecipientCommand(int NotificationListId, string Name, string? EmailAddress, string? PhoneNumber);
|
||||
/// <summary>Deletes one recipient without rewriting unrelated list membership.</summary>
|
||||
public record DeleteNotificationRecipientCommand(int RecipientId);
|
||||
/// <summary>Creates an email configuration using the update path's validation and secret projection.</summary>
|
||||
public record CreateSmtpConfigCommand(string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null, string? OAuth2Authority = null, string? OAuth2Scope = null, string? Transport = null)
|
||||
: UpdateSmtpConfigCommand(0, Server, Port, AuthMode, FromAddress, TlsMode, Credentials, OAuth2Authority, OAuth2Scope, Transport);
|
||||
/// <summary>Creates an SMS configuration using the same validation as updates.</summary>
|
||||
public record CreateSmsConfigCommand(string AccountSid, string? FromNumber, string? MessagingServiceSid = null, string? ApiBaseUrl = null, string? AuthToken = null, int? ConnectionTimeoutSeconds = null, int? MaxRetries = null, TimeSpan? RetryDelay = null)
|
||||
: UpdateSmsConfigCommand(0, AccountSid, FromNumber, MessagingServiceSid, ApiBaseUrl, AuthToken, ConnectionTimeoutSeconds, MaxRetries, RetryDelay);
|
||||
|
||||
@@ -54,7 +54,8 @@ public record NotificationSubmitAck(
|
||||
/// </summary>
|
||||
public record NotificationStatusQuery(
|
||||
string CorrelationId,
|
||||
string NotificationId);
|
||||
string NotificationId,
|
||||
string? VerifiedSourceSiteId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Site: response carrying the current delivery status for a queried notification.
|
||||
|
||||
@@ -30,6 +30,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
/// transition time the DCL OBSERVED the ack at (MxAccess Gateway). Non-null only while the
|
||||
/// condition is active AND acknowledged, so a re-raise (which arrives unacknowledged) clears it.
|
||||
/// </param>
|
||||
/// <param name="SnapshotSourceReference">Exact feed binding for a scoped snapshot, including empty for the OPC UA Server feed; null means connection-wide legacy framing.</param>
|
||||
/// <param name="SnapshotId">Refresh epoch shared by start, entries and end; null preserves legacy snapshot framing.</param>
|
||||
public record NativeAlarmTransition(
|
||||
string SourceReference,
|
||||
string SourceObjectReference,
|
||||
@@ -45,4 +47,6 @@ public record NativeAlarmTransition(
|
||||
DateTimeOffset TransitionTime,
|
||||
string CurrentValue,
|
||||
string LimitValue,
|
||||
DateTimeOffset? AckTime = null);
|
||||
DateTimeOffset? AckTime = null,
|
||||
string? SnapshotSourceReference = null,
|
||||
Guid? SnapshotId = null);
|
||||
|
||||
@@ -14,5 +14,7 @@ public enum AlarmTransitionKind
|
||||
Acknowledge,
|
||||
Clear,
|
||||
Retrigger,
|
||||
StateChange
|
||||
StateChange,
|
||||
/// <summary>Begins a source-scoped refresh epoch; appended to preserve existing wire values.</summary>
|
||||
SnapshotStart
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.Collections;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
/// <summary>Bounds audit batches using complete protobuf row sizes, length/tag prefixes, and gRPC framing.</summary>
|
||||
public static class AuditWireBudget
|
||||
{
|
||||
/// <summary>Five-byte gRPC frame plus two bytes reserved for a true MoreAvailable field.</summary>
|
||||
public const int InitialBytes = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Appends one row to a field-1 repeated-message collection if it fits. A row larger than the
|
||||
/// normal budget travels alone, up to the hard channel ceiling. Oversize rows are retained
|
||||
/// locally and fail explicitly with their identity; they are never acknowledged or truncated.
|
||||
/// </summary>
|
||||
public static bool TryAppend<T>(RepeatedField<T> rows, T row, ref int wireBytes, string rowIdentity)
|
||||
where T : IMessage<T>
|
||||
{
|
||||
var rowBytes = 1L + CodedOutputStream.ComputeMessageSize(row);
|
||||
if (rows.Count > 0 && wireBytes + rowBytes > GrpcMessageLimits.AuditBatchBytes) return false;
|
||||
if (rowBytes + InitialBytes > GrpcMessageLimits.MaxMessageBytes)
|
||||
throw new RpcException(new Status(StatusCode.ResourceExhausted,
|
||||
$"Audit row {rowIdentity} requires {rowBytes + InitialBytes} wire bytes, exceeding {GrpcMessageLimits.MaxMessageBytes}; retained locally. Reduce its payload or raise coordinated peer limits before retrying."));
|
||||
rows.Add(row);
|
||||
wireBytes += (int)rowBytes;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ public sealed class CentralChannelProvider : IDisposable
|
||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
||||
EnableMultipleHttp2Connections = true,
|
||||
},
|
||||
}.WithSiteCredentials(pskProvider, siteId);
|
||||
}.WithMessageLimits().WithSiteCredentials(pskProvider, siteId);
|
||||
|
||||
_channels[i] = GrpcChannel.ForAddress(endpoints[i], channelOptions);
|
||||
_clients[i] = new CentralControlService.CentralControlServiceClient(_channels[i]);
|
||||
|
||||
@@ -56,8 +56,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
/// to the peer central node only when the call provably never ran. So:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="StatusCode.Unavailable"/> — this node is not ready; nothing was dispatched,
|
||||
/// so a cross-node retry is safe and correct.</item>
|
||||
/// <item><see cref="StatusCode.Unavailable"/> plus the server-owned not-started trailer — this
|
||||
/// node is not ready; nothing was dispatched, so a cross-node retry is safe. Unavailable alone
|
||||
/// can also be generated by a transport after application and does not prove retry safety.</item>
|
||||
/// <item><see cref="StatusCode.DeadlineExceeded"/> — the <c>Ask</c> timed out. The message WAS
|
||||
/// delivered and may have been processed; retrying it on the other node would duplicate work.</item>
|
||||
/// <item><see cref="StatusCode.Internal"/> — the handler faulted (a piped
|
||||
@@ -170,7 +171,7 @@ public sealed class CentralControlGrpcService : CentralControlService.CentralCon
|
||||
var central = RequireReady(context);
|
||||
var response = await AskAsync<NotificationStatusResponse>(
|
||||
central,
|
||||
CentralControlDtoMapper.FromDto(request),
|
||||
CentralControlDtoMapper.FromDto(request) with { VerifiedSourceSiteId = CentralControlSiteIdentity.RequireVerified(context) },
|
||||
_options.NotificationForwardTimeout,
|
||||
context).ConfigureAwait(false);
|
||||
|
||||
@@ -332,9 +333,7 @@ public sealed class CentralControlGrpcService : CentralControlService.CentralCon
|
||||
+ "the peer central node).",
|
||||
context.Method);
|
||||
|
||||
throw new RpcException(new GrpcStatus(
|
||||
StatusCode.Unavailable,
|
||||
"Central control plane is not ready: the actor system is still starting."));
|
||||
throw GrpcRetrySafety.NotDispatched("Central control plane is not ready: the actor system is still starting.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using Grpc.Core;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
/// <summary>Verified site identity established by the central PSK interceptor, never by a request body.</summary>
|
||||
public static class CentralControlSiteIdentity
|
||||
{
|
||||
private static readonly object IdentityKey = new();
|
||||
|
||||
/// <summary>Stores an identity only after the site's credential has been verified.</summary>
|
||||
public static void SetVerified(ServerCallContext context, string siteId) => context.UserState[IdentityKey] = siteId;
|
||||
|
||||
/// <summary>Requires a credential-verified identity on the current call.</summary>
|
||||
public static string RequireVerified(ServerCallContext context) =>
|
||||
context.UserState.TryGetValue(IdentityKey, out var value) && value is string site && !string.IsNullOrWhiteSpace(site)
|
||||
? site : throw Denied();
|
||||
|
||||
/// <summary>Checks every ownership field before dispatching any part of a central control request.</summary>
|
||||
public static void ValidateRequest(object request, ServerCallContext context)
|
||||
{
|
||||
var site = RequireVerified(context);
|
||||
void Owns(string? owner)
|
||||
{
|
||||
if (!string.Equals(site, owner, StringComparison.Ordinal)) throw Denied();
|
||||
}
|
||||
switch (request)
|
||||
{
|
||||
case NotificationSubmitDto n: Owns(n.SourceSiteId); break;
|
||||
case ReconcileSiteRequestDto n: Owns(n.SiteIdentifier); break;
|
||||
case SiteHealthReportDto n: Owns(n.SiteId); break;
|
||||
case HeartbeatDto n: Owns(n.SiteId); break;
|
||||
case AuditEventBatch batch:
|
||||
foreach (var row in batch.Events) Owns(row.SourceSiteId);
|
||||
break;
|
||||
case CachedTelemetryBatch batch:
|
||||
foreach (var packet in batch.Packets)
|
||||
{
|
||||
Owns(packet.AuditEvent?.SourceSiteId);
|
||||
Owns(packet.Operational?.SourceSite);
|
||||
}
|
||||
break;
|
||||
// The query carries no claimed owner. The service passes the verified identity to
|
||||
// the outbox, which compares it with the stored notification's SourceSiteId.
|
||||
case NotificationStatusQueryDto: break;
|
||||
case Google.Protobuf.IMessage: throw Denied(); // new RPCs must explicitly choose an ownership rule
|
||||
}
|
||||
}
|
||||
|
||||
private static RpcException Denied() => new(new Status(StatusCode.PermissionDenied, "Control-plane site ownership denied."));
|
||||
}
|
||||
@@ -26,11 +26,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
/// layers already treat as transient.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cross-node retry only on provably-unsent failures.</b> An <see cref="StatusCode.Unavailable"/>
|
||||
/// (connection refused / node not ready) flips the channel pair and retries once on the peer.
|
||||
/// A <see cref="StatusCode.DeadlineExceeded"/> is NEVER retried across nodes — a deploy / write /
|
||||
/// failover may already have executed, and duplicating it is worse than surfacing a transient
|
||||
/// failure the layer above tolerates.
|
||||
/// <b>Cross-node retry is operation-specific.</b> Read-only status and idempotent notification,
|
||||
/// audit and sequence-guarded health ingestion may retry transient transport failures. Reconcile
|
||||
/// mints tokens and retries only an explicit server pre-dispatch rejection. Ambiguous reconcile
|
||||
/// failures return OUTCOME_UNKNOWN. Both attempts share one absolute deadline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Per-call deadlines mirror today's Ask timeouts.</b> Notification submit/status →
|
||||
@@ -71,7 +70,7 @@ public sealed class GrpcCentralTransport : ICentralTransport
|
||||
var dto = CentralControlDtoMapper.ToDto(message);
|
||||
Dispatch(replyTo, _options.NotificationForwardTimeout,
|
||||
(c, o) => c.SubmitNotificationAsync(dto, o),
|
||||
ack => CentralControlDtoMapper.FromDto(ack));
|
||||
ack => CentralControlDtoMapper.FromDto(ack), retrySafe: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -80,7 +79,7 @@ public sealed class GrpcCentralTransport : ICentralTransport
|
||||
var dto = CentralControlDtoMapper.ToDto(message);
|
||||
Dispatch(replyTo, _options.NotificationForwardTimeout,
|
||||
(c, o) => c.QueryNotificationStatusAsync(dto, o),
|
||||
response => CentralControlDtoMapper.FromDto(response));
|
||||
response => CentralControlDtoMapper.FromDto(response), retrySafe: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -89,7 +88,7 @@ public sealed class GrpcCentralTransport : ICentralTransport
|
||||
var batch = CentralControlDtoMapper.ToDto(message);
|
||||
Dispatch(replyTo, SiteStreamGrpcServer.AuditIngestAskTimeout,
|
||||
(c, o) => c.IngestAuditEventsAsync(batch, o),
|
||||
ack => new IngestAuditEventsReply(CentralControlDtoMapper.FromIngestAck(ack)));
|
||||
ack => new IngestAuditEventsReply(CentralControlDtoMapper.FromIngestAck(ack)), retrySafe: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -98,7 +97,7 @@ public sealed class GrpcCentralTransport : ICentralTransport
|
||||
var batch = CentralControlDtoMapper.ToDto(message);
|
||||
Dispatch(replyTo, SiteStreamGrpcServer.AuditIngestAskTimeout,
|
||||
(c, o) => c.IngestCachedTelemetryAsync(batch, o),
|
||||
ack => new IngestCachedTelemetryReply(CentralControlDtoMapper.FromIngestAck(ack)));
|
||||
ack => new IngestCachedTelemetryReply(CentralControlDtoMapper.FromIngestAck(ack)), retrySafe: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -116,7 +115,7 @@ public sealed class GrpcCentralTransport : ICentralTransport
|
||||
var dto = CentralControlDtoMapper.ToDto(message);
|
||||
Dispatch(replyTo, _options.HealthReportTimeout,
|
||||
(c, o) => c.ReportSiteHealthAsync(dto, o),
|
||||
ack => CentralControlDtoMapper.FromDto(ack));
|
||||
ack => CentralControlDtoMapper.FromDto(ack), retrySafe: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -156,34 +155,39 @@ public sealed class GrpcCentralTransport : ICentralTransport
|
||||
IActorRef replyTo,
|
||||
TimeSpan timeout,
|
||||
Func<CentralControlService.CentralControlServiceClient, CallOptions, AsyncUnaryCall<TWire>> call,
|
||||
Func<TWire, object> decode)
|
||||
Func<TWire, object> decode, bool retrySafe = false)
|
||||
{
|
||||
_ = DispatchAsync(replyTo, timeout, call, decode);
|
||||
_ = DispatchAsync(replyTo, timeout, call, decode, retrySafe);
|
||||
}
|
||||
|
||||
private async Task DispatchAsync<TWire>(
|
||||
IActorRef replyTo,
|
||||
TimeSpan timeout,
|
||||
Func<CentralControlService.CentralControlServiceClient, CallOptions, AsyncUnaryCall<TWire>> call,
|
||||
Func<TWire, object> decode)
|
||||
Func<TWire, object> decode, bool retrySafe = false)
|
||||
{
|
||||
var (index, client) = _channels.Current();
|
||||
var deadline = DateTime.UtcNow.Add(timeout);
|
||||
try
|
||||
{
|
||||
var reply = await InvokeAsync(client, timeout, call).ConfigureAwait(false);
|
||||
var reply = await InvokeAsync(client, deadline, call).ConfigureAwait(false);
|
||||
replyTo.Tell(decode(reply));
|
||||
}
|
||||
catch (RpcException ex) when (IsConnectFailure(ex))
|
||||
{
|
||||
// Provably unsent: the connection was refused / the node was not ready. Fail over
|
||||
// to the peer and retry ONCE. This is the only status we retry across nodes.
|
||||
_channels.ReportUnavailable(index);
|
||||
if (!retrySafe && !GrpcRetrySafety.WasNotDispatched(ex))
|
||||
{
|
||||
replyTo.Tell(new AkkaStatus.Failure(GrpcRetrySafety.OutcomeUnknown(ex)));
|
||||
return;
|
||||
}
|
||||
// Retry only a read/idempotent ingest or an explicit server pre-dispatch rejection.
|
||||
var (retryIndex, retryClient) = _channels.Current();
|
||||
if (retryIndex != index)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reply = await InvokeAsync(retryClient, timeout, call).ConfigureAwait(false);
|
||||
var reply = await InvokeAsync(retryClient, deadline, call).ConfigureAwait(false);
|
||||
replyTo.Tell(decode(reply));
|
||||
return;
|
||||
}
|
||||
@@ -191,7 +195,10 @@ public sealed class GrpcCentralTransport : ICentralTransport
|
||||
{
|
||||
_logger.LogWarning(retryEx,
|
||||
"Central control-plane call failed on both endpoints; surfacing as transient.");
|
||||
replyTo.Tell(new AkkaStatus.Failure(retryEx));
|
||||
var reported = retryEx is RpcException rpc && IsConnectFailure(rpc)
|
||||
&& !retrySafe && !GrpcRetrySafety.WasNotDispatched(rpc)
|
||||
? GrpcRetrySafety.OutcomeUnknown(rpc) : retryEx;
|
||||
replyTo.Tell(new AkkaStatus.Failure(reported));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -209,27 +216,18 @@ public sealed class GrpcCentralTransport : ICentralTransport
|
||||
|
||||
private static async Task<TWire> InvokeAsync<TWire>(
|
||||
CentralControlService.CentralControlServiceClient client,
|
||||
TimeSpan timeout,
|
||||
DateTime deadline,
|
||||
Func<CentralControlService.CentralControlServiceClient, CallOptions, AsyncUnaryCall<TWire>> call)
|
||||
{
|
||||
var options = new CallOptions(deadline: DateTime.UtcNow.Add(timeout));
|
||||
var options = new CallOptions(deadline: deadline);
|
||||
using var asyncCall = call(client, options);
|
||||
return await asyncCall.ResponseAsync.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A failure that provably never reached a server — the only class safe to retry on the peer.
|
||||
/// <see cref="StatusCode.DeadlineExceeded"/> is deliberately excluded (the call may have run).
|
||||
/// Identifies transient connection failures for endpoint selection. This does NOT prove
|
||||
/// that a request was unsent; the caller separately checks operation replay safety.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two shapes qualify: a server-signalled <see cref="StatusCode.Unavailable"/> (e.g. a node
|
||||
/// that returns Unavailable while it is still starting), and a client-side failure to even
|
||||
/// start the call — Grpc.Net surfaces a refused/failed connection as
|
||||
/// <see cref="StatusCode.Internal"/> "Error starting gRPC call" with the transport exception
|
||||
/// attached, and there the request never left the client. Anything else — including a deadline,
|
||||
/// a permission denial, or a generic server-side Internal after the call reached the server —
|
||||
/// is NOT retried across nodes.
|
||||
/// </remarks>
|
||||
private static bool IsConnectFailure(RpcException ex)
|
||||
{
|
||||
if (ex.StatusCode == StatusCode.Unavailable)
|
||||
@@ -237,8 +235,8 @@ public sealed class GrpcCentralTransport : ICentralTransport
|
||||
return true;
|
||||
}
|
||||
|
||||
// "Error starting gRPC call" is Grpc.Net's marker for a call that could not be sent — a
|
||||
// refused/failed connection carrying an HttpRequestException. Provably unsent.
|
||||
// A transport-originated Internal can also select a new endpoint; it provides no
|
||||
// permission to repeat a non-idempotent operation.
|
||||
return ex.StatusCode == StatusCode.Internal
|
||||
&& (ex.Status.DebugException is HttpRequestException
|
||||
|| ex.Status.Detail.StartsWith("Error starting gRPC call", StringComparison.Ordinal));
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Grpc.Net.Client;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
/// <summary>Shared wire ceilings for both ends of the control plane.</summary>
|
||||
public static class GrpcMessageLimits
|
||||
{
|
||||
/// <summary>Maximum serialized message size, including all fields and protobuf framing.</summary>
|
||||
public const int MaxMessageBytes = 32 * 1024 * 1024;
|
||||
|
||||
/// <summary>Normal audit batch budget, leaving ample room below the wire ceiling.</summary>
|
||||
public const int AuditBatchBytes = 2 * 1024 * 1024;
|
||||
|
||||
/// <summary>Applies the control-plane limits to a client channel.</summary>
|
||||
/// <param name="options">Channel options, including the caller's handler and credentials.</param>
|
||||
/// <returns>The supplied options for chaining.</returns>
|
||||
public static GrpcChannelOptions WithMessageLimits(this GrpcChannelOptions options)
|
||||
{
|
||||
options.MaxReceiveMessageSize = MaxMessageBytes;
|
||||
options.MaxSendMessageSize = MaxMessageBytes;
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Grpc.Core;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
/// <summary>Separates an explicit server rejection before dispatch from a lost response after possible application.</summary>
|
||||
public static class GrpcRetrySafety
|
||||
{
|
||||
/// <summary>Server-owned trailer emitted only before any actor dispatch.</summary>
|
||||
public const string DispatchTrailer = "scadabridge-dispatch";
|
||||
|
||||
/// <summary>Creates a readiness rejection that proves no work was dispatched.</summary>
|
||||
public static RpcException NotDispatched(string detail) => new(
|
||||
new Status(StatusCode.Unavailable, detail), new Metadata { { DispatchTrailer, "not-started" } });
|
||||
|
||||
/// <summary>True only for an explicit rejection from the server before it dispatched work.</summary>
|
||||
public static bool WasNotDispatched(RpcException error) => error.StatusCode == StatusCode.Unavailable
|
||||
&& error.Trailers.Any(e => e.Key == DispatchTrailer && e.Value == "not-started");
|
||||
|
||||
/// <summary>Reports an ambiguous mutation without inviting a blind replay.</summary>
|
||||
public static RpcException OutcomeUnknown(RpcException error) => new(
|
||||
new Status(StatusCode.Aborted, "OUTCOME_UNKNOWN: connection failed after the command may have been applied. Verify its state before retrying."),
|
||||
error.Trailers);
|
||||
}
|
||||
@@ -28,8 +28,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
/// path, which the S&F/audit layers already treat as transient.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cross-node retry is the channel provider's job</b> and happens only on
|
||||
/// <c>Unavailable</c> — never on <c>DeadlineExceeded</c> (a write/deploy/failover may have run).
|
||||
/// <b>Cross-node retry is operation-specific.</b> Read-only commands can retry Unavailable;
|
||||
/// mutations require an explicit server rejection before dispatch. A lost mutation response
|
||||
/// returns OUTCOME_UNKNOWN, never an automatic duplicate. Deadlines are not replayed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GrpcSiteTransport : ISiteCommandTransport
|
||||
@@ -125,9 +126,15 @@ public sealed class GrpcSiteTransport : ISiteCommandTransport
|
||||
return _channels.ExecuteAsync(
|
||||
envelope.SiteId,
|
||||
(channel, callCt) => InvokeAsync(channel, group, message, deadline, callCt),
|
||||
ct);
|
||||
ct, retrySafe: IsRetrySafe(message));
|
||||
}
|
||||
|
||||
/// <summary>Only read-only commands can replay after a response may have been lost.</summary>
|
||||
internal static bool IsRetrySafe(object message) => message is
|
||||
DeploymentStateQueryRequest or BrowseNodeCommand or SearchAddressSpaceCommand or ReadTagValuesCommand
|
||||
or ListServerCertsCommand or EventLogQueryRequest or DebugSnapshotRequest or ParkedMessageQueryRequest
|
||||
or RouteToGetAttributesRequest;
|
||||
|
||||
private static async Task<object> InvokeAsync(
|
||||
GrpcChannel channel, SiteCommandGroup group, object message, DateTime deadline, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -196,8 +196,7 @@ public sealed class SiteCommandGrpcService : SiteCommandService.SiteCommandServi
|
||||
var dispatcher = _dispatcher;
|
||||
if (!_ready || dispatcher is null)
|
||||
{
|
||||
throw new RpcException(new GrpcStatus(
|
||||
StatusCode.Unavailable, "Site command plane not ready."));
|
||||
throw GrpcRetrySafety.NotDispatched("Site command plane not ready.");
|
||||
}
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
@@ -31,13 +31,10 @@ public sealed class SiteChannelUnavailableException(string siteId)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Retry safety.</b> <see cref="ExecuteAsync{T}"/> retries on the other node ONLY when the first
|
||||
/// attempt failed with <see cref="StatusCode.Unavailable"/> (provably never reached a server —
|
||||
/// connect refused or readiness-rejected before response headers). A
|
||||
/// <see cref="StatusCode.DeadlineExceeded"/>, or any other status, is rethrown WITHOUT a cross-node
|
||||
/// retry: a <c>WriteTag</c>/<c>DeployArtifacts</c>/<c>TriggerSiteFailover</c> may already have
|
||||
/// executed, and the layers above tolerate the one-shot ambiguity exactly as ClusterClient's Ask
|
||||
/// did — the migration must not turn one write into two.
|
||||
/// <b>Retry safety.</b> Unavailable can mean a lost response after application. Only explicitly
|
||||
/// retry-safe operations or a server-owned not-started trailer permit replay on the peer.
|
||||
/// Ambiguous mutations fail with Aborted / OUTCOME_UNKNOWN; the next independent call uses the
|
||||
/// peer. Deadlines are never replayed. Readiness rejection remains safe before actor dispatch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Addresses.</b> Fed by <see cref="UpdateSite"/>/<see cref="RemoveSite"/> from the single DB
|
||||
@@ -188,17 +185,18 @@ public sealed class SitePairChannelProvider : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="call"/> against the site's current sticky channel, failing over to the
|
||||
/// other node once on <see cref="StatusCode.Unavailable"/> (never on
|
||||
/// <see cref="StatusCode.DeadlineExceeded"/>). Ensures the background failback loop is running.
|
||||
/// other node only for a retry-safe operation or proven pre-dispatch rejection. An ambiguous
|
||||
/// mutation returns OUTCOME_UNKNOWN. Ensures the background failback loop is running.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The RPC reply type.</typeparam>
|
||||
/// <param name="siteId">Target site.</param>
|
||||
/// <param name="call">The RPC to run against a given channel.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <param name="retrySafe">Whether repeating this operation is safe after an ambiguous transport failure.</param>
|
||||
/// <returns>The RPC reply.</returns>
|
||||
/// <exception cref="SiteChannelUnavailableException">The site has no configured channel.</exception>
|
||||
public async Task<T> ExecuteAsync<T>(
|
||||
string siteId, Func<GrpcChannel, CancellationToken, Task<T>> call, CancellationToken ct)
|
||||
string siteId, Func<GrpcChannel, CancellationToken, Task<T>> call, CancellationToken ct, bool retrySafe = false)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(siteId);
|
||||
ArgumentNullException.ThrowIfNull(call);
|
||||
@@ -227,14 +225,22 @@ public sealed class SitePairChannelProvider : IDisposable
|
||||
{
|
||||
return await call(primary, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable && secondary is not null)
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable)
|
||||
{
|
||||
// Provably never reached a server → safe to try the other node, even for a write.
|
||||
if (secondary is not null) FlipTo(pair, toIsA: !primaryIsA);
|
||||
if (!retrySafe && !GrpcRetrySafety.WasNotDispatched(ex))
|
||||
throw GrpcRetrySafety.OutcomeUnknown(ex);
|
||||
if (secondary is null) throw;
|
||||
// Only reads/idempotent operations or an explicit pre-dispatch rejection may replay.
|
||||
_logger.LogWarning(
|
||||
"Site {SiteId} node {FromNode} unavailable; failing over to node {ToNode}",
|
||||
siteId, primaryIsA ? "A" : "B", primaryIsA ? "B" : "A");
|
||||
FlipTo(pair, toIsA: !primaryIsA);
|
||||
return await call(secondary, ct).ConfigureAwait(false);
|
||||
try { return await call(secondary, ct).ConfigureAwait(false); }
|
||||
catch (RpcException retryError) when (retryError.StatusCode == StatusCode.Unavailable
|
||||
&& !retrySafe && !GrpcRetrySafety.WasNotDispatched(retryError))
|
||||
{
|
||||
throw GrpcRetrySafety.OutcomeUnknown(retryError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,7 +438,7 @@ public sealed class SitePairChannelProvider : IDisposable
|
||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
||||
EnableMultipleHttp2Connections = true
|
||||
}
|
||||
}.WithSiteCredentials(_pskProvider, siteId);
|
||||
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId);
|
||||
|
||||
return GrpcChannel.ForAddress(endpoint, channelOptions);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
KeepAlivePingTimeout = options.GrpcKeepAlivePingTimeout,
|
||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always
|
||||
}
|
||||
}.WithSiteCredentials(pskProvider, siteIdentifier));
|
||||
}.WithMessageLimits().WithSiteCredentials(pskProvider, siteIdentifier));
|
||||
_client = new SiteStreamService.SiteStreamServiceClient(_channel);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -792,9 +792,14 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
// (plus, once it sets after_id, that row's EventId).
|
||||
MoreAvailable = events.Count >= request.BatchSize,
|
||||
};
|
||||
var wireBytes = AuditWireBudget.InitialBytes;
|
||||
foreach (var evt in events)
|
||||
{
|
||||
response.Events.Add(AuditEventDtoMapper.ToDto(evt));
|
||||
if (!AuditWireBudget.TryAppend(response.Events, AuditEventDtoMapper.ToDto(evt), ref wireBytes, evt.EventId.ToString()))
|
||||
{
|
||||
response.MoreAvailable = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -860,9 +865,14 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
// is upsert-on-newer, so re-reading rows is a harmless no-op.
|
||||
MoreAvailable = operationals.Count >= request.BatchSize,
|
||||
};
|
||||
var wireBytes = AuditWireBudget.InitialBytes;
|
||||
foreach (var op in operationals)
|
||||
{
|
||||
response.Operationals.Add(SiteCallDtoMapper.ToDto(op));
|
||||
if (!AuditWireBudget.TryAppend(response.Operationals, SiteCallDtoMapper.ToDto(op), ref wireBytes, op.TrackedOperationId.ToString()))
|
||||
{
|
||||
response.MoreAvailable = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
+10
-1
@@ -181,8 +181,16 @@ VALUES
|
||||
@LastError, @HttpStatus, @CreatedAtUtc, @UpdatedAtUtc, @TerminalAtUtc, @IngestedAtUtc);";
|
||||
|
||||
// Leg 2 of the upsert: the monotonic guard. Also re-run standalone from the
|
||||
// duplicate-key catch, which is why it lives in its own constant.
|
||||
// duplicate-key catch, which is why it lives in its own constant. Ownership
|
||||
// is checked here as well as in the UPDATE predicate: an insert-race loser
|
||||
// must not advance another site's operation. Throwing rolls back the paired
|
||||
// audit/operational ingest transaction instead of acknowledging forged state.
|
||||
private const string MonotonicUpdateSql = @"
|
||||
IF EXISTS (SELECT 1 FROM dbo.SiteCalls
|
||||
WHERE TrackedOperationId = @Id
|
||||
AND SourceSite COLLATE Latin1_General_100_BIN2 <> @SourceSite COLLATE Latin1_General_100_BIN2)
|
||||
THROW 51001, 'Site-call operation ownership denied.', 1;
|
||||
|
||||
UPDATE dbo.SiteCalls
|
||||
SET Status = @Status,
|
||||
RetryCount = @RetryCount,
|
||||
@@ -193,6 +201,7 @@ SET Status = @Status,
|
||||
IngestedAtUtc = @IngestedAtUtc,
|
||||
SourceNode = COALESCE(@SourceNode, SourceNode)
|
||||
WHERE TrackedOperationId = @Id
|
||||
AND SourceSite COLLATE Latin1_General_100_BIN2 = @SourceSite COLLATE Latin1_General_100_BIN2
|
||||
AND ( @Rank > (CASE Status
|
||||
WHEN 'Submitted' THEN 0
|
||||
WHEN 'Forwarded' THEN 1
|
||||
|
||||
@@ -74,6 +74,10 @@ public static class ServiceCollectionExtensions
|
||||
// registration of the same interface is idempotent.
|
||||
services.TryAddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||
|
||||
services.AddSingleton<IConfigurationCommandTransaction, ConfigurationCommandTransaction>();
|
||||
services.AddScoped<ConfigurationCommitActions>();
|
||||
services.AddScoped<IConfigurationCommitActions>(sp => sp.GetRequiredService<ConfigurationCommitActions>());
|
||||
services.AddScoped<IConfigurationReadSession, ConfigurationReadSession>();
|
||||
services.AddScoped<ISecurityRepository, SecurityRepository>();
|
||||
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
|
||||
/// <summary>Owns the SQL transaction spanning configuration saves and the final audit flush.</summary>
|
||||
public sealed class ConfigurationCommandTransaction(IServiceScopeFactory scopes) : IConfigurationCommandTransaction
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation)
|
||||
{
|
||||
using var strategyScope = scopes.CreateScope();
|
||||
var strategy = strategyScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Database.CreateExecutionStrategy();
|
||||
var commitStarted = false;
|
||||
var result = await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
// A lost commit acknowledgment must never automatically replay a create.
|
||||
if (commitStarted) throw new InvalidOperationException("Configuration commit outcome is uncertain; inspect the configuration audit before retrying.");
|
||||
await using var attempt = scopes.CreateAsyncScope();
|
||||
var db = attempt.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
var value = await operation(attempt.ServiceProvider);
|
||||
await db.SaveChangesAsync(); // AuditService stages rows; flush them inside this transaction.
|
||||
commitStarted = true;
|
||||
await transaction.CommitAsync();
|
||||
await attempt.ServiceProvider.GetRequiredService<ConfigurationCommitActions>().RunAsync();
|
||||
return value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Per-attempt postcommit actions; rolled-back attempts never run them.</summary>
|
||||
public sealed class ConfigurationCommitActions(ILogger<ConfigurationCommitActions> logger) : IConfigurationCommitActions
|
||||
{
|
||||
private readonly List<Func<Task>> _actions = [];
|
||||
/// <inheritdoc />
|
||||
public void Add(Func<Task> action) => _actions.Add(action);
|
||||
internal async Task RunAsync()
|
||||
{
|
||||
foreach (var action in _actions)
|
||||
{
|
||||
try { await action(); }
|
||||
catch (Exception ex) { logger.LogError(ex, "Configuration committed, but local cache maintenance failed"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Refreshes circuit-held read entities after a command owned by another scope.</summary>
|
||||
public sealed class ConfigurationReadSession(ScadaBridgeDbContext db) : IConfigurationReadSession
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Reset() => db.ChangeTracker.Clear();
|
||||
}
|
||||
@@ -12,12 +12,12 @@ using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Connection actor using Akka.NET Become/Stash pattern for lifecycle state machine.
|
||||
/// Connection actor using Akka.NET Become for its lifecycle state machine.
|
||||
///
|
||||
/// States:
|
||||
/// - Connecting: stash subscribe/write requests; attempts connection
|
||||
/// - Connected: unstash and process all requests
|
||||
/// - Reconnecting: push bad quality for all subscribed tags, stash new requests,
|
||||
/// - Connecting: retain latest subscription requests, reject writes; attempts connection
|
||||
/// - Connected: process pending subscriptions and live requests
|
||||
/// - Reconnecting: push bad quality, retain latest subscriptions and reject writes,
|
||||
/// fixed-interval reconnect
|
||||
///
|
||||
/// Auto-reconnect with bad quality on disconnect.
|
||||
@@ -27,7 +27,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
||||
/// Health reporting (connection status + tag resolution counts).
|
||||
/// Subscription lifecycle (register on create, cleanup on stop).
|
||||
/// </summary>
|
||||
public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
public class DataConnectionActor : UntypedActor, IWithTimers
|
||||
{
|
||||
public enum ActiveEndpoint { Primary, Backup }
|
||||
|
||||
@@ -45,8 +45,6 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
private string _protocolType;
|
||||
private readonly ISiteEventLogger? _siteEventLogger;
|
||||
|
||||
/// <summary>Stash for holding messages while the connection is not in the Connected state.</summary>
|
||||
public IStash Stash { get; set; } = null!;
|
||||
/// <summary>Timer scheduler for reconnect and tag-resolution retry timers.</summary>
|
||||
public ITimerScheduler Timers { get; set; } = null!;
|
||||
|
||||
@@ -413,13 +411,23 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// declaring the same dead adapter Connected anyway.
|
||||
NotePendingDisconnect(disconnected);
|
||||
break;
|
||||
case SubscribeTagsRequest:
|
||||
case WriteTagRequest:
|
||||
case WriteTagBatchRequest:
|
||||
case UnsubscribeTagsRequest:
|
||||
case SubscribeAlarmsRequest:
|
||||
case UnsubscribeAlarmsRequest:
|
||||
Stash.Stash();
|
||||
case SubscribeTagsRequest pending:
|
||||
QueueSubscribe(pending);
|
||||
break;
|
||||
case SubscribeAlarmsRequest pendingAlarm:
|
||||
QueueSubscribeAlarms(pendingAlarm);
|
||||
break;
|
||||
case WriteTagRequest write:
|
||||
RejectDisconnectedWrite(write);
|
||||
break;
|
||||
case WriteTagBatchRequest batch:
|
||||
RejectDisconnectedWrite(batch);
|
||||
break;
|
||||
case UnsubscribeTagsRequest unsubscribe:
|
||||
HandleUnsubscribe(unsubscribe);
|
||||
break;
|
||||
case UnsubscribeAlarmsRequest unsubscribeAlarm:
|
||||
HandleUnsubscribeAlarms(unsubscribeAlarm);
|
||||
break;
|
||||
case ReconfigureConnectionCommand rc:
|
||||
// NEVER stashed. A reconfigure that arrived while the initial connect was still
|
||||
@@ -469,6 +477,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// ConnectionNotConnected.
|
||||
HandleReadTagValues(read);
|
||||
break;
|
||||
case Terminated terminated:
|
||||
HandleSubscriberTerminated(terminated.ActorRef);
|
||||
break;
|
||||
case GetHealthReport:
|
||||
ReplyWithHealthReport();
|
||||
break;
|
||||
@@ -491,7 +502,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// Register row 54 — the only transition that raises the connected gauge.
|
||||
ScadaBridgeTelemetry.RecordDataConnectionState(_connectionName, ConnectionHealth.Connected);
|
||||
Become(Connected);
|
||||
Stash.UnstashAll();
|
||||
foreach (var pending in _pendingTagSubscriptions.Values)
|
||||
Self.Tell(pending.Request, pending.Owner);
|
||||
_pendingTagSubscriptions.Clear();
|
||||
foreach (var (key, request) in _pendingAlarmSubscriptions)
|
||||
Self.Tell(request, key.Owner);
|
||||
_pendingAlarmSubscriptions.Clear();
|
||||
}
|
||||
|
||||
private void Connected(object message)
|
||||
@@ -563,6 +579,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
case ReadTagValuesCommand read:
|
||||
HandleReadTagValues(read);
|
||||
break;
|
||||
case Terminated terminated:
|
||||
HandleSubscriberTerminated(terminated.ActorRef);
|
||||
break;
|
||||
case GetHealthReport:
|
||||
ReplyWithHealthReport();
|
||||
break;
|
||||
@@ -789,11 +808,17 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// adapter that will never raise Disconnected a second time.
|
||||
NotePendingDisconnect(disconnected);
|
||||
break;
|
||||
case SubscribeTagsRequest:
|
||||
case WriteTagRequest:
|
||||
case WriteTagBatchRequest:
|
||||
case SubscribeAlarmsRequest:
|
||||
Stash.Stash();
|
||||
case SubscribeTagsRequest pending:
|
||||
QueueSubscribe(pending);
|
||||
break;
|
||||
case SubscribeAlarmsRequest pendingAlarm:
|
||||
QueueSubscribeAlarms(pendingAlarm);
|
||||
break;
|
||||
case WriteTagRequest write:
|
||||
RejectDisconnectedWrite(write);
|
||||
break;
|
||||
case WriteTagBatchRequest batch:
|
||||
RejectDisconnectedWrite(batch);
|
||||
break;
|
||||
case UnsubscribeTagsRequest req:
|
||||
// Allow unsubscribe even during reconnect (for cleanup on instance stop)
|
||||
@@ -850,6 +875,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// to a ConnectionNotConnected failure.
|
||||
HandleReadTagValues(read);
|
||||
break;
|
||||
case Terminated terminated:
|
||||
HandleSubscriberTerminated(terminated.ActorRef);
|
||||
break;
|
||||
case GetHealthReport:
|
||||
ReplyWithHealthReport();
|
||||
break;
|
||||
@@ -1110,6 +1138,52 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
BecomeReconnecting();
|
||||
}
|
||||
|
||||
// One latest request per owner/source while disconnected; retries cannot grow a stash.
|
||||
private readonly Dictionary<string, (SubscribeTagsRequest Request, IActorRef Owner)> _pendingTagSubscriptions = new();
|
||||
private readonly Dictionary<(IActorRef Owner, string Source), SubscribeAlarmsRequest> _pendingAlarmSubscriptions = new();
|
||||
|
||||
private void QueueSubscribe(SubscribeTagsRequest request)
|
||||
{
|
||||
Context.Watch(Sender);
|
||||
_pendingTagSubscriptions.TryGetValue(request.InstanceUniqueName, out var previous);
|
||||
_pendingTagSubscriptions[request.InstanceUniqueName] = (request, Sender);
|
||||
if (previous.Owner is not null && !previous.Owner.Equals(Sender))
|
||||
UnwatchIfUnused(previous.Owner);
|
||||
}
|
||||
|
||||
private void QueueSubscribeAlarms(SubscribeAlarmsRequest request)
|
||||
{
|
||||
Context.Watch(Sender);
|
||||
_pendingAlarmSubscriptions[(Sender, request.SourceReference)] = request;
|
||||
}
|
||||
|
||||
private void RejectDisconnectedWrite(WriteTagRequest request) => Sender.Tell(new WriteTagResponse(
|
||||
request.CorrelationId, false, "Connection is not connected; device write was not queued.", DateTimeOffset.UtcNow));
|
||||
|
||||
private void RejectDisconnectedWrite(WriteTagBatchRequest request) => Sender.Tell(new WriteTagBatchResponse(
|
||||
request.CorrelationId, false, "Connection is not connected; device write was not queued.", DateTimeOffset.UtcNow));
|
||||
|
||||
private void UnwatchIfUnused(IActorRef owner)
|
||||
{
|
||||
if (!_subscribers.Values.Contains(owner)
|
||||
&& !_alarmSourceSubscribers.Values.Any(subs => subs.Contains(owner))
|
||||
&& !_pendingTagSubscriptions.Values.Any(p => p.Owner.Equals(owner))
|
||||
&& !_pendingAlarmSubscriptions.Keys.Any(k => k.Owner.Equals(owner)))
|
||||
Context.Unwatch(owner);
|
||||
}
|
||||
|
||||
private void HandleSubscriberTerminated(IActorRef owner)
|
||||
{
|
||||
foreach (var instance in _pendingTagSubscriptions.Where(kv => kv.Value.Owner.Equals(owner)).Select(kv => kv.Key).ToList())
|
||||
_pendingTagSubscriptions.Remove(instance);
|
||||
foreach (var key in _pendingAlarmSubscriptions.Keys.Where(k => k.Owner.Equals(owner)).ToList())
|
||||
_pendingAlarmSubscriptions.Remove(key);
|
||||
foreach (var instance in _subscribers.Where(kv => kv.Value.Equals(owner)).Select(kv => kv.Key).ToList())
|
||||
RemoveTagSubscriber(instance, owner);
|
||||
foreach (var source in _alarmSourceSubscribers.Where(kv => kv.Value.Contains(owner)).Select(kv => kv.Key).ToList())
|
||||
RemoveAlarmSubscriber(source, owner);
|
||||
}
|
||||
|
||||
// ── Subscription Management ──
|
||||
|
||||
private void HandleSubscribe(SubscribeTagsRequest request)
|
||||
@@ -1117,10 +1191,21 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
_log.Debug("[{0}] Subscribing {1} tags for instance {2}",
|
||||
_connectionName, request.TagPaths.Count, request.InstanceUniqueName);
|
||||
|
||||
Context.Watch(Sender);
|
||||
if (_subscribers.TryGetValue(request.InstanceUniqueName, out var previousOwner) && !previousOwner.Equals(Sender))
|
||||
RemoveTagSubscriber(request.InstanceUniqueName, previousOwner, request.TagPaths.ToHashSet());
|
||||
_subscribers[request.InstanceUniqueName] = Sender;
|
||||
if (previousOwner is not null && !previousOwner.Equals(Sender))
|
||||
UnwatchIfUnused(previousOwner);
|
||||
|
||||
if (!_subscriptionsByInstance.ContainsKey(request.InstanceUniqueName))
|
||||
_subscriptionsByInstance[request.InstanceUniqueName] = new HashSet<string>();
|
||||
if (!_subscriptionsByInstance.TryGetValue(request.InstanceUniqueName, out var requestedTags))
|
||||
_subscriptionsByInstance[request.InstanceUniqueName] = requestedTags = new HashSet<string>();
|
||||
// Record demand before I/O, so a replacement owner can adopt a shared in-flight result.
|
||||
foreach (var tag in request.TagPaths)
|
||||
{
|
||||
requestedTags.Add(tag);
|
||||
IndexTag(tag, request.InstanceUniqueName);
|
||||
}
|
||||
|
||||
var self = Self;
|
||||
var sender = Sender;
|
||||
@@ -1148,7 +1233,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
var preResolvedResults = new List<SubscribeTagResult>();
|
||||
foreach (var tagPath in request.TagPaths)
|
||||
{
|
||||
if (_subscriptionIds.ContainsKey(tagPath) || _subscribesInFlight.Contains(tagPath))
|
||||
if (_subscriptionIds.ContainsKey(tagPath) || _subscribesInFlight.Contains(tagPath) || _resolutionInFlight.Contains(tagPath))
|
||||
{
|
||||
preResolvedResults.Add(new SubscribeTagResult(
|
||||
tagPath, AlreadySubscribed: true, Success: true, null, null));
|
||||
@@ -1540,10 +1625,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// wholesale once the replacement adapter connects, and a marker the new generation
|
||||
// has since placed for the same tag must not be removed by this dead message.
|
||||
|
||||
// Reply failure so the requester re-sends. Nothing has registered this request's
|
||||
// tags in _subscriptionsByInstance (registration happens below, on the applied
|
||||
// path), so ReSubscribeAll cannot pick them up — the InstanceActor's per-connection
|
||||
// retry timer, armed until a Success arrives, is what re-establishes them.
|
||||
// Demand was registered before I/O, so ReSubscribeAll can recover it on the
|
||||
// replacement adapter. Reply failure as well so the owner's correlated retry
|
||||
// state never treats this stale completion as an established subscription.
|
||||
msg.ReplyTo.Tell(new SubscribeTagsResponse(
|
||||
msg.Request.CorrelationId, instanceName, false,
|
||||
"adapter replaced while the subscribe was in flight — will retry",
|
||||
@@ -1551,53 +1635,17 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_subscriptionsByInstance.TryGetValue(instanceName, out var instanceTags))
|
||||
if (!_subscribers.TryGetValue(instanceName, out var currentOwner) || !currentOwner.Equals(msg.ReplyTo)
|
||||
|| !_subscriptionsByInstance.TryGetValue(instanceName, out var instanceTags))
|
||||
{
|
||||
// The instance was unsubscribed while the
|
||||
// subscribe I/O was in flight. Re-creating the per-instance entry and
|
||||
// applying the handle mutations here would permanently leak state
|
||||
// — _subscriptionsByInstance[instanceName] resurrected with no
|
||||
// subscriber to receive callbacks, _instancesByTag re-indexed against
|
||||
// an instance no future HandleUnsubscribe will ever visit, and the
|
||||
// health counts derived from both reporting tags nobody subscribes to
|
||||
// for the rest of the adapter lifetime (also re-issued by ReSubscribeAll
|
||||
// on every reconnect). Instead: drop all state mutations for this stale
|
||||
// message and release the adapter-level monitored items we just
|
||||
// created so the device doesn't keep streaming change notifications
|
||||
// for a tag nobody is subscribed to.
|
||||
_log.Warning(
|
||||
"[{0}] SubscribeCompleted arrived for instance {1} but the instance " +
|
||||
"was unsubscribed while the subscribe was in flight; releasing " +
|
||||
"{2} adapter handle(s) and discarding state mutations.",
|
||||
_connectionName, instanceName, msg.Results.Count(r => r.Success && !r.AlreadySubscribed));
|
||||
|
||||
var orphanedIds = new List<string>();
|
||||
foreach (var result in msg.Results)
|
||||
{
|
||||
// Clear in-flight markers we placed in HandleSubscribe.
|
||||
if (!result.AlreadySubscribed)
|
||||
_subscribesInFlight.Remove(result.TagPath);
|
||||
|
||||
// Fire-and-forget release of any subscription id this request
|
||||
// genuinely created. AlreadySubscribed=true means another caller
|
||||
// owns the adapter handle and unsubscribing it would break them.
|
||||
if (result is { Success: true, AlreadySubscribed: false, SubscriptionId: not null })
|
||||
{
|
||||
orphanedIds.Add(result.SubscriptionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Against the adapter that created them (identical to _adapter here — the
|
||||
// generation guard above already proved the completion is current — but stating
|
||||
// the ownership rule at every release site is what keeps it true).
|
||||
_ = UnsubscribeIdsAsync(msg.Adapter, orphanedIds);
|
||||
|
||||
// The original sender is already gone (unsubscribed). Telling a dead
|
||||
// ref produces a dead letter, which is the harmless and observable
|
||||
// outcome — but skipping the reply altogether keeps dead-letter noise
|
||||
// out of the log when this race fires in the normal disable/redeploy
|
||||
// path. The unsubscribe message did NOT request a response of its own.
|
||||
return false;
|
||||
// The initiating owner may be gone, but another instance/replacement can
|
||||
// still need these tags. Reconcile against CURRENT per-tag demand instead
|
||||
// of unconditionally releasing handles owned by shared in-flight work.
|
||||
foreach (var row in msg.Results.Where(r => !r.AlreadySubscribed))
|
||||
_subscribesInFlight.Remove(row.TagPath);
|
||||
HandleBatchSubscribeCompleted(new BatchSubscribeCompleted(
|
||||
msg.Results.Where(r => !r.AlreadySubscribed).ToList(), msg.AdapterGeneration, BatchSubscribeSource.Resubscribe));
|
||||
return msg.Results.Any(r => r.ConnectionLevelFailure && _instancesByTag.ContainsKey(r.TagPath));
|
||||
}
|
||||
|
||||
// If any tag failed because the adapter is not
|
||||
@@ -1647,7 +1695,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// monitored item forever — _subscriptionIds holds a single id per tag, so
|
||||
// no later unsubscribe can ever reference this one. Release it, exactly as
|
||||
// HandleBatchSubscribeCompleted does for its duplicate rows.
|
||||
if (result is { Success: true, SubscriptionId: not null })
|
||||
if (result is { Success: true, SubscriptionId: not null }
|
||||
&& _subscriptionIds[result.TagPath] != result.SubscriptionId)
|
||||
redundantIds.Add(result.SubscriptionId);
|
||||
continue;
|
||||
}
|
||||
@@ -1798,10 +1847,16 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
|
||||
private void HandleUnsubscribe(UnsubscribeTagsRequest request)
|
||||
{
|
||||
_log.Debug("[{0}] Unsubscribing all tags for instance {1}",
|
||||
_connectionName, request.InstanceUniqueName);
|
||||
if (_pendingTagSubscriptions.TryGetValue(request.InstanceUniqueName, out var pending) && pending.Owner.Equals(Sender))
|
||||
_pendingTagSubscriptions.Remove(request.InstanceUniqueName);
|
||||
RemoveTagSubscriber(request.InstanceUniqueName, Sender);
|
||||
UnwatchIfUnused(Sender);
|
||||
}
|
||||
|
||||
if (!_subscriptionsByInstance.TryGetValue(request.InstanceUniqueName, out var tags))
|
||||
private void RemoveTagSubscriber(string instanceName, IActorRef owner, HashSet<string>? preserveTags = null)
|
||||
{
|
||||
if (!_subscribers.TryGetValue(instanceName, out var currentOwner) || !currentOwner.Equals(owner)
|
||||
|| !_subscriptionsByInstance.TryGetValue(instanceName, out var tags))
|
||||
return;
|
||||
|
||||
// Released adapter handles are collected and released in ONE round trip below
|
||||
@@ -1809,7 +1864,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
var idsToRelease = new List<string>();
|
||||
|
||||
// Cleanup on Instance Actor stop
|
||||
foreach (var tagPath in tags)
|
||||
foreach (var tagPath in tags.Where(t => preserveTags == null || !preserveTags.Contains(t)).ToList())
|
||||
{
|
||||
// Drop this instance from the per-tag counted set. UnindexTag removes the tag
|
||||
// key entirely when its last subscriber leaves, so "the key is gone" IS the
|
||||
@@ -1818,7 +1873,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// keys this loop retires, whatever state the tag was in (resolved, unresolved,
|
||||
// or mid-reconnect with both maps cleared — the case the retired scalar
|
||||
// counters silently failed to decrement at all).
|
||||
UnindexTag(tagPath, request.InstanceUniqueName);
|
||||
tags.Remove(tagPath);
|
||||
UnindexTag(tagPath, instanceName);
|
||||
if (_instancesByTag.ContainsKey(tagPath))
|
||||
continue;
|
||||
|
||||
@@ -1851,8 +1907,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
_resolutionInFlight.Remove(tagPath);
|
||||
}
|
||||
|
||||
_subscriptionsByInstance.Remove(request.InstanceUniqueName);
|
||||
_subscribers.Remove(request.InstanceUniqueName);
|
||||
if (preserveTags is null)
|
||||
{
|
||||
_subscriptionsByInstance.Remove(instanceName);
|
||||
_subscribers.Remove(instanceName);
|
||||
UnwatchIfUnused(owner);
|
||||
}
|
||||
|
||||
// Fire-and-forget release of every handle this unsubscribe orphaned.
|
||||
_ = UnsubscribeIdsAsync(_adapter, idsToRelease);
|
||||
@@ -2274,7 +2334,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// Only dispatch retries for tags that do not already
|
||||
// have an attempt in flight. A slow subscribe overlapping the next tick
|
||||
// would otherwise produce duplicate concurrent subscribes for the same tag.
|
||||
var toResolve = _unresolvedTags.Where(t => !_resolutionInFlight.Contains(t)).ToList();
|
||||
var toResolve = _unresolvedTags.Where(t => !_resolutionInFlight.Contains(t) && !_subscribesInFlight.Contains(t)).ToList();
|
||||
|
||||
if (toResolve.Count == 0)
|
||||
{
|
||||
@@ -2506,7 +2566,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// Another path already stored a handle for this tag while this one was
|
||||
// in flight — release the redundant handle instead of leaking it
|
||||
// (mirrors the duplicate-alarm-feed guard).
|
||||
idsToRelease.Add(row.SubscriptionId);
|
||||
if (_subscriptionIds[row.TagPath] != row.SubscriptionId)
|
||||
idsToRelease.Add(row.SubscriptionId);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2860,6 +2921,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
IndexAlarmSource(request.SourceReference);
|
||||
}
|
||||
subs.Add(subscriber);
|
||||
Context.Watch(subscriber);
|
||||
// S10: the adapter feed carries a single shared condition filter per source.
|
||||
// A second subscriber with a different filter silently overwrites the first
|
||||
// (last-writer-wins). Surface it so a co-subscriber mismatch is diagnosable —
|
||||
@@ -3008,19 +3070,20 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
var transition = msg.Transition;
|
||||
var notified = new HashSet<IActorRef>();
|
||||
|
||||
// A SnapshotComplete is a connection-wide framing sentinel, not a real
|
||||
// condition: the mapper emits it with an empty SourceReference /
|
||||
// SourceObjectReference. It must reach EVERY alarm subscriber so each
|
||||
// NativeAlarmActor can atomically swap in the snapshot it just buffered.
|
||||
// The per-source prefix match below would drop it ("".StartsWith("<src>.")
|
||||
// is false), which would strand statically-active conditions that are only
|
||||
// delivered in the snapshot (no later live transition) — they would buffer
|
||||
// forever and never surface. Broadcast the sentinel to all subscribers,
|
||||
// bypassing the source match and the condition-type filter (the sentinel
|
||||
// carries no condition; the buffered entries were already filtered).
|
||||
if (transition.Kind == AlarmTransitionKind.SnapshotComplete)
|
||||
// OPC UA refresh boundaries belong to one monitored source, whereas Mx
|
||||
// uses one connection-wide alarm stream. Boundaries bypass condition filters
|
||||
// but must retain their scope: broadcasting a second source's end marker
|
||||
// would clear the first source's already-committed snapshot.
|
||||
if (transition.Kind is AlarmTransitionKind.SnapshotStart or AlarmTransitionKind.SnapshotComplete)
|
||||
{
|
||||
foreach (var subs in _alarmSourceSubscribers.Values)
|
||||
// Explicit scope distinguishes the empty OPC UA Server binding from the
|
||||
// connection-wide Mx stream. Legacy empty-scope sentinels stay connection-wide.
|
||||
var scope = transition.SnapshotSourceReference
|
||||
?? (string.IsNullOrEmpty(transition.SourceObjectReference) ? null : transition.SourceObjectReference);
|
||||
var targets = scope is null
|
||||
? _alarmSourceSubscribers.Values
|
||||
: _alarmSourceSubscribers.Where(kv => kv.Key == scope).Select(kv => kv.Value);
|
||||
foreach (var subs in targets)
|
||||
foreach (var sub in subs)
|
||||
if (notified.Add(sub))
|
||||
sub.Tell(new NativeAlarmTransitionUpdate(_connectionName, transition));
|
||||
@@ -3137,24 +3200,30 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
|
||||
private void HandleUnsubscribeAlarms(UnsubscribeAlarmsRequest request)
|
||||
{
|
||||
if (!_alarmSourceSubscribers.TryGetValue(request.SourceReference, out var subs))
|
||||
return;
|
||||
_pendingAlarmSubscriptions.Remove((Sender, request.SourceReference));
|
||||
RemoveAlarmSubscriber(request.SourceReference, Sender);
|
||||
UnwatchIfUnused(Sender);
|
||||
}
|
||||
|
||||
subs.Remove(Sender);
|
||||
private void RemoveAlarmSubscriber(string sourceReference, IActorRef owner)
|
||||
{
|
||||
if (!_alarmSourceSubscribers.TryGetValue(sourceReference, out var subs) || !subs.Remove(owner))
|
||||
return;
|
||||
UnwatchIfUnused(owner);
|
||||
if (subs.Count > 0)
|
||||
return;
|
||||
|
||||
// No subscribers remain for this source — tear down the adapter feed.
|
||||
_alarmSourceSubscribers.Remove(request.SourceReference);
|
||||
UnindexAlarmSource(request.SourceReference);
|
||||
_alarmSourceFilter.Remove(request.SourceReference);
|
||||
_alarmSourceFilterPredicate.Remove(request.SourceReference);
|
||||
_alarmSourceSubscribers.Remove(sourceReference);
|
||||
UnindexAlarmSource(sourceReference);
|
||||
_alarmSourceFilter.Remove(sourceReference);
|
||||
_alarmSourceFilterPredicate.Remove(sourceReference);
|
||||
// Clear the in-flight marker so that if an adapter
|
||||
// subscribe is still in flight for this source, the late AlarmSubscribeCompleted
|
||||
// is recognized as orphaned (its guard checks _alarmSourceSubscribers, now empty)
|
||||
// and the just-created feed is released rather than stored and leaked.
|
||||
_alarmSubscribesInFlight.Remove(request.SourceReference);
|
||||
if (_alarmSubscriptionIds.Remove(request.SourceReference, out var subId) &&
|
||||
_alarmSubscribesInFlight.Remove(sourceReference);
|
||||
if (_alarmSubscriptionIds.Remove(sourceReference, out var subId) &&
|
||||
_adapter is IAlarmSubscribableConnection alarmable)
|
||||
{
|
||||
_ = alarmable.UnsubscribeAlarmsAsync(subId);
|
||||
|
||||
@@ -263,7 +263,7 @@ public class MxGatewayDataConnection
|
||||
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_subs.TryRemove(subscriptionId, out var s))
|
||||
_tagToSub.TryRemove(s.TagPath, out _);
|
||||
((ICollection<KeyValuePair<string, string>>)_tagToSub).Remove(new(s.TagPath, subscriptionId));
|
||||
await _client!.UnsubscribeAsync(subscriptionId, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ public class MxGatewayDataConnection
|
||||
foreach (var id in subscriptionIds)
|
||||
{
|
||||
if (_subs.TryRemove(id, out var s))
|
||||
_tagToSub.TryRemove(s.TagPath, out _);
|
||||
((ICollection<KeyValuePair<string, string>>)_tagToSub).Remove(new(s.TagPath, id));
|
||||
}
|
||||
|
||||
await _client!.UnsubscribeBulkAsync(subscriptionIds, cancellationToken);
|
||||
|
||||
@@ -216,7 +216,7 @@ public sealed class RealMxGatewayClient : IMxGatewayClient
|
||||
// UnsubscribeBulk unadvises AND removes the item, releasing every advice kind.
|
||||
_supervisoryAdvised.TryRemove(handle, out _);
|
||||
if (_handleToTag.TryRemove(handle, out var tag))
|
||||
_tagToHandle.TryRemove(tag, out _);
|
||||
((ICollection<KeyValuePair<string, int>>)_tagToHandle).Remove(new(tag, handle));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ public sealed class RealMxGatewayClient : IMxGatewayClient
|
||||
// so forget any supervisory-advise state for this handle.
|
||||
_supervisoryAdvised.TryRemove(handle, out _);
|
||||
if (_handleToTag.TryRemove(handle, out var tag))
|
||||
_tagToHandle.TryRemove(tag, out _);
|
||||
((ICollection<KeyValuePair<string, int>>)_tagToHandle).Remove(new(tag, handle));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -68,6 +68,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
private readonly ConcurrentDictionary<string, MonitoredItem> _alarmItems = new();
|
||||
// Per-handle "currently inside a ConditionRefresh replay" flag → Snapshot kind.
|
||||
private readonly ConcurrentDictionary<string, bool> _alarmInRefresh = new();
|
||||
private readonly ConcurrentDictionary<string, Guid> _alarmSnapshotIds = new();
|
||||
// Per-handle last (active, acked) by source reference, to derive transition kind.
|
||||
private readonly ConcurrentDictionary<string, Dictionary<string, (bool Active, bool Acked)>> _alarmLastState = new();
|
||||
// Int flag toggled with Interlocked.Exchange so the
|
||||
@@ -543,6 +544,10 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
}
|
||||
_monitoredItems.Clear();
|
||||
_callbacks.Clear();
|
||||
_alarmItems.Clear();
|
||||
_alarmInRefresh.Clear();
|
||||
_alarmSnapshotIds.Clear();
|
||||
_alarmLastState.Clear();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -925,6 +930,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
}, cancellationToken);
|
||||
}
|
||||
_alarmInRefresh.TryRemove(subscriptionHandle, out _);
|
||||
_alarmSnapshotIds.TryRemove(subscriptionHandle, out _);
|
||||
_alarmLastState.TryRemove(subscriptionHandle, out _);
|
||||
}
|
||||
|
||||
@@ -1160,12 +1166,16 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
if (eventType == ObjectTypeIds.RefreshStartEventType)
|
||||
{
|
||||
_alarmInRefresh[handle] = true;
|
||||
var startEpoch = Guid.NewGuid();
|
||||
_alarmSnapshotIds[handle] = startEpoch;
|
||||
onTransition(SnapshotBoundary(AlarmTransitionKind.SnapshotStart, subscriptionSourceReference, startEpoch));
|
||||
return;
|
||||
}
|
||||
if (eventType == ObjectTypeIds.RefreshEndEventType)
|
||||
{
|
||||
_alarmInRefresh[handle] = false;
|
||||
onTransition(SnapshotComplete());
|
||||
if (_alarmSnapshotIds.TryRemove(handle, out var endEpoch))
|
||||
onTransition(SnapshotBoundary(AlarmTransitionKind.SnapshotComplete, subscriptionSourceReference, endEpoch));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1253,13 +1263,16 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
LimitValue: limitValue,
|
||||
// MES alarm-status API §6.4: true source ack instant (index 18) when supplied,
|
||||
// else the observed event time; null unless the condition is active AND acked.
|
||||
AckTime: OpcUaAlarmMapper.DeriveAckTime(active, acked, sourceAckTime, time)));
|
||||
AckTime: OpcUaAlarmMapper.DeriveAckTime(active, acked, sourceAckTime, time),
|
||||
SnapshotSourceReference: inRefresh ? subscriptionSourceReference ?? string.Empty : null,
|
||||
SnapshotId: inRefresh && _alarmSnapshotIds.TryGetValue(handle, out var epoch) ? epoch : null));
|
||||
}
|
||||
|
||||
private static NativeAlarmTransition SnapshotComplete() => new(
|
||||
"", "", "", AlarmTransitionKind.SnapshotComplete,
|
||||
private static NativeAlarmTransition SnapshotBoundary(AlarmTransitionKind kind, string? source, Guid epoch) => new(
|
||||
"", source ?? string.Empty, "", kind,
|
||||
new Commons.Types.Alarms.AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
|
||||
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");
|
||||
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "",
|
||||
SnapshotSourceReference: source ?? string.Empty, SnapshotId: epoch);
|
||||
|
||||
/// <summary>
|
||||
/// Nodes per OPC UA Read/Write service call. Stays under the
|
||||
|
||||
@@ -99,6 +99,7 @@ public sealed class CentralControlAuthInterceptor : Interceptor
|
||||
UnaryServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
await AuthorizeAsync(context).ConfigureAwait(false);
|
||||
if (IsGated(context.Method)) CentralControlSiteIdentity.ValidateRequest(request, context);
|
||||
return await continuation(request, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -187,6 +188,8 @@ public sealed class CentralControlAuthInterceptor : Interceptor
|
||||
presented is null ? "no bearer token presented" : "bearer token did not match");
|
||||
throw Denied("control plane authentication failed");
|
||||
}
|
||||
|
||||
CentralControlSiteIdentity.SetVerified(context, siteId);
|
||||
}
|
||||
|
||||
private static RpcException Denied(string reason)
|
||||
|
||||
@@ -290,6 +290,8 @@ try
|
||||
// removed ClusterClient path used to carry, and Asks CentralCommunicationActor — zero handler logic here.
|
||||
builder.Services.AddGrpc(options =>
|
||||
{
|
||||
options.MaxReceiveMessageSize = ZB.MOM.WW.ScadaBridge.Communication.Grpc.GrpcMessageLimits.MaxMessageBytes;
|
||||
options.MaxSendMessageSize = ZB.MOM.WW.ScadaBridge.Communication.Grpc.GrpcMessageLimits.MaxMessageBytes;
|
||||
options.Interceptors.Add<CentralControlAuthInterceptor>();
|
||||
});
|
||||
builder.Services.AddSingleton<
|
||||
@@ -824,6 +826,8 @@ try
|
||||
// why every environment must carry one before running this build.
|
||||
builder.Services.AddGrpc(options =>
|
||||
{
|
||||
options.MaxReceiveMessageSize = ZB.MOM.WW.ScadaBridge.Communication.Grpc.GrpcMessageLimits.MaxMessageBytes;
|
||||
options.MaxSendMessageSize = ZB.MOM.WW.ScadaBridge.Communication.Grpc.GrpcMessageLimits.MaxMessageBytes;
|
||||
options.Interceptors.Add<LocalDbSyncAuthInterceptor>();
|
||||
options.Interceptors.Add<ControlPlaneAuthInterceptor>();
|
||||
});
|
||||
|
||||
@@ -151,6 +151,14 @@ public class InboundScriptExecutor
|
||||
/// <param name="methodName">The method name to remove.</param>
|
||||
public void RemoveHandler(string methodName) => InvalidateMethod(methodName);
|
||||
|
||||
/// <summary>Checks compilation without publishing or invalidating a live handler.</summary>
|
||||
public bool ValidateCompilation(ApiMethod method, out IReadOnlyList<string> errors)
|
||||
{
|
||||
var (handler, diagnostics) = Compile(method);
|
||||
errors = diagnostics;
|
||||
return handler is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles and registers a single API method script. Returns <c>false</c> if the
|
||||
/// script is empty, fails Roslyn compilation, or violates the script trust model.
|
||||
|
||||
@@ -156,7 +156,9 @@ public class ManagementActor : ReceiveActor
|
||||
private async Task<object> ProcessCommand(ManagementEnvelope envelope, AuthenticatedUser user)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user, envelope.ClientIp);
|
||||
var result = IsConfigurationCommand(envelope.Command)
|
||||
? await ExecuteConfigurationCommandAsync(scope.ServiceProvider, envelope.Command, user, envelope.ClientIp)
|
||||
: await DispatchCommand(scope.ServiceProvider, envelope.Command, user, envelope.ClientIp);
|
||||
return new ManagementSuccess(envelope.CorrelationId, SerializeResult(result));
|
||||
}
|
||||
|
||||
@@ -187,6 +189,41 @@ public class ManagementActor : ReceiveActor
|
||||
return new ManagementError(correlationId, clientMessage, errorCode);
|
||||
}
|
||||
|
||||
/// <summary>Audited database-only application boundary shared by UI and management envelopes.</summary>
|
||||
public static async Task<object?> ExecuteConfigurationCommandAsync(IServiceProvider services, object command, AuthenticatedUser user, string? clientIp = null)
|
||||
{
|
||||
if (!IsConfigurationCommand(command)) throw new ArgumentException("Not a configuration command.", nameof(command));
|
||||
var required = GetRequiredRoles(command);
|
||||
if (required is null || !required.Any(r => user.Roles.Contains(r, StringComparer.OrdinalIgnoreCase)))
|
||||
throw new UnauthorizedAccessException("The current session is not authorized for this configuration change.");
|
||||
var transaction = services.GetRequiredService<IConfigurationCommandTransaction>();
|
||||
return await transaction.ExecuteAsync(sp => DispatchCommand(sp, command, user, clientIp));
|
||||
}
|
||||
|
||||
private static bool IsConfigurationCommand(object command) => command is
|
||||
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
|
||||
or CreateAreaCommand or UpdateAreaCommand or MoveAreaCommand or DeleteAreaCommand or SetInstanceAreaCommand
|
||||
or CreateDataConnectionCommand or UpdateDataConnectionCommand or DeleteDataConnectionCommand
|
||||
or CreateExternalSystemCommand or UpdateExternalSystemCommand or DeleteExternalSystemCommand
|
||||
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
|
||||
or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
|
||||
or CreateRoleMappingCommand or UpdateRoleMappingCommand or DeleteRoleMappingCommand or AddScopeRuleCommand or DeleteScopeRuleCommand
|
||||
or CreateNotificationListCommand or UpdateNotificationListCommand or DeleteNotificationListCommand
|
||||
or UpdateSmtpConfigCommand or UpdateSmsConfigCommand
|
||||
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
|
||||
or SetInstanceNativeAlarmSourceOverrideCommand or DeleteInstanceNativeAlarmSourceOverrideCommand
|
||||
or SetInstanceNativeAlarmSourceOverridesCommand or SetInstanceOverridesCommand
|
||||
or RenameNotificationListCommand or AddNotificationRecipientCommand or DeleteNotificationRecipientCommand
|
||||
or DeleteInstanceAttributeOverrideCommand;
|
||||
|
||||
private static Task AfterConfigurationCommit(IServiceProvider services, Func<Task> action)
|
||||
{
|
||||
var pending = services.GetService<IConfigurationCommitActions>();
|
||||
if (pending is null) return action();
|
||||
pending.Add(action);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Static readonly role sets for the authorization gate. Single-role arms
|
||||
// stay one-element arrays so every arm has the uniform any-of shape.
|
||||
private static readonly string[] AdminOnly = [Roles.Administrator];
|
||||
@@ -226,6 +263,8 @@ public class ManagementActor : ReceiveActor
|
||||
/// <returns>The set of roles that satisfy the authorization gate, or <c>null</c> if any authenticated user may issue the command.</returns>
|
||||
internal static IReadOnlyList<string>? GetRequiredRoles(object command) => command switch
|
||||
{
|
||||
RenameNotificationListCommand or AddNotificationRecipientCommand or DeleteNotificationRecipientCommand => DesignerOnly,
|
||||
DeleteInstanceAttributeOverrideCommand => DeployerOnly,
|
||||
// Administrator operations
|
||||
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
|
||||
or ListRoleMappingsCommand or CreateRoleMappingCommand
|
||||
@@ -335,11 +374,15 @@ public class ManagementActor : ReceiveActor
|
||||
_ => null
|
||||
};
|
||||
|
||||
private async Task<object?> DispatchCommand(
|
||||
private static async Task<object?> DispatchCommand(
|
||||
IServiceProvider sp, object command, AuthenticatedUser user, string? clientIp = null)
|
||||
{
|
||||
return command switch
|
||||
{
|
||||
RenameNotificationListCommand cmd => await HandleRenameNotificationList(sp, cmd, user.Username),
|
||||
AddNotificationRecipientCommand cmd => await HandleAddNotificationRecipient(sp, cmd, user.Username),
|
||||
DeleteNotificationRecipientCommand cmd => await HandleDeleteNotificationRecipient(sp, cmd, user.Username),
|
||||
DeleteInstanceAttributeOverrideCommand cmd => await HandleDeleteAttributeOverride(sp, cmd, user),
|
||||
// Templates
|
||||
ListTemplatesCommand cmd => await HandleListTemplates(sp, cmd),
|
||||
GetTemplateCommand cmd => await HandleGetTemplate(sp, cmd),
|
||||
@@ -403,10 +446,10 @@ public class ManagementActor : ReceiveActor
|
||||
UpdateSiteCommand cmd => await HandleUpdateSite(sp, cmd, user.Username),
|
||||
DeleteSiteCommand cmd => await HandleDeleteSite(sp, cmd, user.Username),
|
||||
ListAreasCommand cmd => await HandleListAreas(sp, cmd, user),
|
||||
CreateAreaCommand cmd => await HandleCreateArea(sp, cmd, user.Username),
|
||||
DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd, user.Username),
|
||||
UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username),
|
||||
MoveAreaCommand cmd => await HandleMoveArea(sp, cmd, user.Username),
|
||||
CreateAreaCommand cmd => await HandleCreateArea(sp, cmd, user),
|
||||
DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd, user),
|
||||
UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user),
|
||||
MoveAreaCommand cmd => await HandleMoveArea(sp, cmd, user),
|
||||
|
||||
// Data Connections
|
||||
ListDataConnectionsCommand cmd => await HandleListDataConnections(sp, cmd, user),
|
||||
@@ -1845,7 +1888,7 @@ public class ManagementActor : ReceiveActor
|
||||
await repo.AddSiteAsync(site);
|
||||
await repo.SaveChangesAsync();
|
||||
var commService = sp.GetService<CommunicationService>();
|
||||
commService?.RefreshSiteAddresses();
|
||||
await AfterConfigurationCommit(sp, () => { commService?.RefreshSiteAddresses(); return Task.CompletedTask; });
|
||||
await AuditAsync(sp, user, "Create", "Site", site.Id.ToString(), site.Name, site);
|
||||
return site;
|
||||
}
|
||||
@@ -1864,7 +1907,7 @@ public class ManagementActor : ReceiveActor
|
||||
await repo.UpdateSiteAsync(site);
|
||||
await repo.SaveChangesAsync();
|
||||
var commService = sp.GetService<CommunicationService>();
|
||||
commService?.RefreshSiteAddresses();
|
||||
await AfterConfigurationCommit(sp, () => { commService?.RefreshSiteAddresses(); return Task.CompletedTask; });
|
||||
await AuditAsync(sp, user, "Update", "Site", site.Id.ToString(), site.Name, site);
|
||||
return site;
|
||||
}
|
||||
@@ -1880,7 +1923,7 @@ public class ManagementActor : ReceiveActor
|
||||
await repo.DeleteSiteAsync(cmd.SiteId);
|
||||
await repo.SaveChangesAsync();
|
||||
var commService = sp.GetService<CommunicationService>();
|
||||
commService?.RefreshSiteAddresses();
|
||||
await AfterConfigurationCommit(sp, () => { commService?.RefreshSiteAddresses(); return Task.CompletedTask; });
|
||||
|
||||
// Dispose the deleted site's cached gRPC channels — RemoveSiteAsync is the
|
||||
// factory's designed site-deletion disposal path and previously had no
|
||||
@@ -1891,7 +1934,7 @@ public class ManagementActor : ReceiveActor
|
||||
// the viewer linger (documented acceptance — see Component-Communication.md).
|
||||
var grpcFactory = sp.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcClientFactory>();
|
||||
if (grpcFactory is not null && site is not null)
|
||||
await grpcFactory.RemoveSiteAsync(site.SiteIdentifier);
|
||||
await AfterConfigurationCommit(sp, () => grpcFactory.RemoveSiteAsync(site.SiteIdentifier));
|
||||
|
||||
await AuditAsync(sp, user, "Delete", "Site", cmd.SiteId.ToString(), site?.Name ?? cmd.SiteId.ToString(), null);
|
||||
return true;
|
||||
@@ -1904,27 +1947,20 @@ public class ManagementActor : ReceiveActor
|
||||
return await repo.GetAreaTreeBySiteIdAsync(cmd.SiteId);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleCreateArea(IServiceProvider sp, CreateAreaCommand cmd, string user)
|
||||
private static async Task<object?> HandleCreateArea(IServiceProvider sp, CreateAreaCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var area = new Area(cmd.Name)
|
||||
{
|
||||
SiteId = cmd.SiteId,
|
||||
ParentAreaId = cmd.ParentAreaId
|
||||
};
|
||||
await repo.AddAreaAsync(area);
|
||||
await repo.SaveChangesAsync();
|
||||
await AuditAsync(sp, user, "Create", "Area", area.Id.ToString(), area.Name, area);
|
||||
return area;
|
||||
EnforceSiteScope(user, cmd.SiteId);
|
||||
var result = await sp.GetRequiredService<AreaService>().CreateAreaAsync(cmd.Name, cmd.SiteId, cmd.ParentAreaId, user.Username);
|
||||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleDeleteArea(IServiceProvider sp, DeleteAreaCommand cmd, string user)
|
||||
private static async Task<object?> HandleDeleteArea(IServiceProvider sp, DeleteAreaCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
await repo.DeleteAreaAsync(cmd.AreaId);
|
||||
await repo.SaveChangesAsync();
|
||||
await AuditAsync(sp, user, "Delete", "Area", cmd.AreaId.ToString(), cmd.AreaId.ToString(), null);
|
||||
return true;
|
||||
var area = await sp.GetRequiredService<ITemplateEngineRepository>().GetAreaByIdAsync(cmd.AreaId)
|
||||
?? throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
|
||||
EnforceSiteScope(user, area.SiteId);
|
||||
var result = await sp.GetRequiredService<AreaService>().DeleteAreaAsync(cmd.AreaId, user.Username);
|
||||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
@@ -2321,6 +2357,58 @@ public class ManagementActor : ReceiveActor
|
||||
// Notification handlers
|
||||
// ========================================================================
|
||||
|
||||
private static async Task<object?> HandleRenameNotificationList(IServiceProvider sp, RenameNotificationListCommand cmd, string user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||||
var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId)
|
||||
?? throw new ManagementCommandException("Notification list not found.");
|
||||
if (string.IsNullOrWhiteSpace(cmd.Name)) throw new ManagementCommandException("Name is required.");
|
||||
list.Name = cmd.Name.Trim();
|
||||
await repo.UpdateNotificationListAsync(list);
|
||||
await repo.SaveChangesAsync();
|
||||
await AuditAsync(sp, user, "Update", "NotificationList", list.Id.ToString(), list.Name, list);
|
||||
return list;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleAddNotificationRecipient(IServiceProvider sp, AddNotificationRecipientCommand cmd, string user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||||
var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId)
|
||||
?? throw new ManagementCommandException("Notification list not found.");
|
||||
if (list.Type == NotificationType.Sms ? string.IsNullOrWhiteSpace(cmd.PhoneNumber) : string.IsNullOrWhiteSpace(cmd.EmailAddress))
|
||||
throw new ManagementCommandException("A contact matching the list's channel is required.");
|
||||
var recipient = list.Type == NotificationType.Sms
|
||||
? NotificationRecipient.ForSms(cmd.Name, cmd.PhoneNumber!) : NotificationRecipient.ForEmail(cmd.Name, cmd.EmailAddress!);
|
||||
recipient.NotificationListId = list.Id;
|
||||
await repo.AddRecipientAsync(recipient);
|
||||
await repo.SaveChangesAsync();
|
||||
await AuditAsync(sp, user, "Create", "NotificationRecipient", recipient.Id.ToString(), recipient.Name, recipient);
|
||||
return recipient;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleDeleteNotificationRecipient(IServiceProvider sp, DeleteNotificationRecipientCommand cmd, string user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||||
var recipient = await repo.GetRecipientByIdAsync(cmd.RecipientId)
|
||||
?? throw new ManagementCommandException("Recipient not found.");
|
||||
await repo.DeleteRecipientAsync(cmd.RecipientId);
|
||||
await repo.SaveChangesAsync();
|
||||
await AuditAsync(sp, user, "Delete", "NotificationRecipient", cmd.RecipientId.ToString(), recipient.Name, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleDeleteAttributeOverride(IServiceProvider sp, DeleteInstanceAttributeOverrideCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var row = (await repo.GetOverridesByInstanceIdAsync(cmd.InstanceId)).FirstOrDefault(x => x.AttributeName == cmd.AttributeName);
|
||||
if (row is null) return true;
|
||||
await repo.DeleteInstanceAttributeOverrideAsync(row.Id);
|
||||
await repo.SaveChangesAsync();
|
||||
await AuditAsync(sp, user.Username, "Delete", "InstanceAttributeOverride", row.Id.ToString(), cmd.AttributeName, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleListNotificationLists(IServiceProvider sp)
|
||||
{
|
||||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||||
@@ -2448,7 +2536,9 @@ public class ManagementActor : ReceiveActor
|
||||
private static async Task<object?> HandleUpdateSmtpConfig(IServiceProvider sp, UpdateSmtpConfigCommand cmd, string user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||||
var config = await repo.GetSmtpConfigurationByIdAsync(cmd.SmtpConfigId)
|
||||
var config = cmd is CreateSmtpConfigCommand
|
||||
? new Commons.Entities.Notifications.SmtpConfiguration(cmd.Server, cmd.AuthMode, cmd.FromAddress)
|
||||
: await repo.GetSmtpConfigurationByIdAsync(cmd.SmtpConfigId)
|
||||
?? throw new ManagementCommandException($"SmtpConfiguration with ID {cmd.SmtpConfigId} not found.");
|
||||
config.Host = cmd.Server;
|
||||
config.Port = cmd.Port;
|
||||
@@ -2466,13 +2556,14 @@ public class ManagementActor : ReceiveActor
|
||||
// one (null on every pre-EWS row, which means SMTP).
|
||||
if (cmd.Transport is not null) config.Transport = cmd.Transport;
|
||||
ValidateSmtpConfigTransportShape(config);
|
||||
await repo.UpdateSmtpConfigurationAsync(config);
|
||||
if (cmd is CreateSmtpConfigCommand) await repo.AddSmtpConfigurationAsync(config);
|
||||
else await repo.UpdateSmtpConfigurationAsync(config);
|
||||
await repo.SaveChangesAsync();
|
||||
// Audit the credential-free shape — the *fact of* the change
|
||||
// (and which non-secret fields hold) is observable; the secret value is
|
||||
// not persisted to the audit log where OperationalAuditRoles can read it.
|
||||
var publicShape = SmtpConfigPublicShape(config);
|
||||
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, publicShape);
|
||||
await AuditAsync(sp, user, cmd is CreateSmtpConfigCommand ? "Create" : "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, publicShape);
|
||||
return publicShape;
|
||||
}
|
||||
|
||||
@@ -2576,7 +2667,9 @@ public class ManagementActor : ReceiveActor
|
||||
{
|
||||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||||
var configs = await repo.GetAllSmsConfigurationsAsync();
|
||||
var config = configs.FirstOrDefault(c => c.Id == cmd.SmsConfigId)
|
||||
var config = cmd is CreateSmsConfigCommand
|
||||
? new Commons.Entities.Notifications.SmsConfiguration(cmd.AccountSid, cmd.FromNumber)
|
||||
: configs.FirstOrDefault(c => c.Id == cmd.SmsConfigId)
|
||||
?? throw new ManagementCommandException($"SmsConfiguration with ID {cmd.SmsConfigId} not found.");
|
||||
// The AccountSid is interpolated un-escaped into the Twilio request URI
|
||||
// (SmsNotificationDeliveryAdapter). Reject any value that is not a well-formed
|
||||
@@ -2597,12 +2690,20 @@ public class ManagementActor : ReceiveActor
|
||||
// subsequent send. (Contrast SmtpConfiguration.Credentials, which MAY be cleared
|
||||
// when switching to anonymous SMTP, so that path keeps its null-only guard.)
|
||||
if (!string.IsNullOrWhiteSpace(cmd.AuthToken)) config.AuthToken = cmd.AuthToken;
|
||||
await repo.UpdateSmsConfigurationAsync(config);
|
||||
if (cmd.ConnectionTimeoutSeconds is { } timeout) config.ConnectionTimeoutSeconds = timeout;
|
||||
if (cmd.MaxRetries is { } retries) config.MaxRetries = retries;
|
||||
if (cmd.RetryDelay is { } retryDelay) config.RetryDelay = retryDelay;
|
||||
if (string.IsNullOrWhiteSpace(config.FromNumber) && string.IsNullOrWhiteSpace(config.MessagingServiceSid))
|
||||
throw new ManagementCommandException("A FromNumber or MessagingServiceSid is required.");
|
||||
if (cmd is CreateSmsConfigCommand && string.IsNullOrWhiteSpace(config.AuthToken))
|
||||
throw new ManagementCommandException("AuthToken is required.");
|
||||
if (cmd is CreateSmsConfigCommand) await repo.AddSmsConfigurationAsync(config);
|
||||
else await repo.UpdateSmsConfigurationAsync(config);
|
||||
await repo.SaveChangesAsync();
|
||||
// Audit the credential-free shape — the AuthToken secret is
|
||||
// not persisted to the audit log where OperationalAuditRoles can read it.
|
||||
var publicShape = SmsConfigPublicShape(config);
|
||||
await AuditAsync(sp, user, "Update", "SmsConfiguration", config.Id.ToString(), config.AccountSid, publicShape);
|
||||
await AuditAsync(sp, user, cmd is CreateSmsConfigCommand ? "Create" : "Update", "SmsConfiguration", config.Id.ToString(), config.AccountSid, publicShape);
|
||||
return publicShape;
|
||||
}
|
||||
|
||||
@@ -3414,6 +3515,8 @@ public class ManagementActor : ReceiveActor
|
||||
{
|
||||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||||
var def = new DatabaseConnectionDefinition(cmd.Name, cmd.ConnectionString);
|
||||
if (cmd.MaxRetries is { } retries) def.MaxRetries = retries;
|
||||
if (cmd.RetryDelay is { } retryDelay) def.RetryDelay = retryDelay;
|
||||
await repo.AddDatabaseConnectionAsync(def);
|
||||
await repo.SaveChangesAsync();
|
||||
await AuditAsync(sp, user, "Create", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
|
||||
@@ -3430,6 +3533,8 @@ public class ManagementActor : ReceiveActor
|
||||
// secret intact — the connection string is elided from List/Get, so a
|
||||
// round-trip must not be required to re-send it.
|
||||
if (cmd.ConnectionString is not null) def.ConnectionString = cmd.ConnectionString;
|
||||
if (cmd.MaxRetries is { } retries) def.MaxRetries = retries;
|
||||
if (cmd.RetryDelay is { } retryDelay) def.RetryDelay = retryDelay;
|
||||
await repo.UpdateDatabaseConnectionAsync(def);
|
||||
await repo.SaveChangesAsync();
|
||||
await AuditAsync(sp, user, "Update", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
|
||||
@@ -3511,7 +3616,7 @@ public class ManagementActor : ReceiveActor
|
||||
await repo.DeleteApiMethodAsync(cmd.ApiMethodId);
|
||||
await repo.SaveChangesAsync();
|
||||
if (method != null)
|
||||
sp.GetService<InboundAPI.InboundScriptExecutor>()?.InvalidateMethod(method.Name);
|
||||
await AfterConfigurationCommit(sp, () => { sp.GetService<InboundAPI.InboundScriptExecutor>()?.InvalidateMethod(method.Name); return Task.CompletedTask; });
|
||||
await AuditAsync(sp, user, "Delete", "ApiMethod", cmd.ApiMethodId.ToString(), method?.Name ?? cmd.ApiMethodId.ToString(), null);
|
||||
return true;
|
||||
}
|
||||
@@ -3532,7 +3637,13 @@ public class ManagementActor : ReceiveActor
|
||||
var executor = sp.GetService<InboundAPI.InboundScriptExecutor>();
|
||||
if (executor is null)
|
||||
return null;
|
||||
if (executor.CompileAndRegister(method, out var errors))
|
||||
var compiled = executor.ValidateCompilation(method, out var errors);
|
||||
var actions = sp.GetService<IConfigurationCommitActions>();
|
||||
if (actions is not null)
|
||||
actions.Add(() => { executor.CompileAndRegister(method); return Task.CompletedTask; });
|
||||
else
|
||||
executor.CompileAndRegister(method);
|
||||
if (compiled)
|
||||
return null;
|
||||
|
||||
var detail = string.Join("; ", errors);
|
||||
@@ -3620,16 +3731,13 @@ public class ManagementActor : ReceiveActor
|
||||
// Area update handler
|
||||
// ========================================================================
|
||||
|
||||
private static async Task<object?> HandleUpdateArea(IServiceProvider sp, UpdateAreaCommand cmd, string user)
|
||||
private static async Task<object?> HandleUpdateArea(IServiceProvider sp, UpdateAreaCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var area = await repo.GetAreaByIdAsync(cmd.AreaId)
|
||||
var area = await sp.GetRequiredService<ITemplateEngineRepository>().GetAreaByIdAsync(cmd.AreaId)
|
||||
?? throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
|
||||
area.Name = cmd.Name;
|
||||
await repo.UpdateAreaAsync(area);
|
||||
await repo.SaveChangesAsync();
|
||||
await AuditAsync(sp, user, "Update", "Area", area.Id.ToString(), area.Name, area);
|
||||
return area;
|
||||
EnforceSiteScope(user, area.SiteId);
|
||||
var result = await sp.GetRequiredService<AreaService>().UpdateAreaAsync(cmd.AreaId, cmd.Name, user.Username);
|
||||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3641,10 +3749,12 @@ public class ManagementActor : ReceiveActor
|
||||
/// response, matching the sibling folder/template move handlers. The service
|
||||
/// writes the "Move" audit row itself, so this handler does not audit again.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleMoveArea(IServiceProvider sp, MoveAreaCommand cmd, string user)
|
||||
private static async Task<object?> HandleMoveArea(IServiceProvider sp, MoveAreaCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
var svc = sp.GetRequiredService<AreaService>();
|
||||
var result = await svc.MoveAreaAsync(cmd.AreaId, cmd.NewParentAreaId, user);
|
||||
var area = await sp.GetRequiredService<ITemplateEngineRepository>().GetAreaByIdAsync(cmd.AreaId)
|
||||
?? throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
|
||||
EnforceSiteScope(user, area.SiteId);
|
||||
var result = await sp.GetRequiredService<AreaService>().MoveAreaAsync(cmd.AreaId, cmd.NewParentAreaId, user.Username);
|
||||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||||
}
|
||||
|
||||
|
||||
@@ -1000,7 +1000,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
||||
var notification = await repository.GetByIdAsync(query.NotificationId);
|
||||
|
||||
if (notification is null)
|
||||
if (notification is null || (query.VerifiedSourceSiteId is { } site
|
||||
&& !string.Equals(notification.SourceSiteId, site, StringComparison.Ordinal)))
|
||||
{
|
||||
return new NotificationStatusResponse(
|
||||
query.CorrelationId, Found: false, Status: string.Empty,
|
||||
|
||||
@@ -41,6 +41,9 @@ public static class ScriptTrustPolicy
|
||||
"System.Reflection",
|
||||
"System.Net",
|
||||
"System.Runtime.InteropServices",
|
||||
"System.Runtime.CompilerServices.Unsafe",
|
||||
"System.Runtime.CompilerServices.RuntimeHelpers",
|
||||
"System.Runtime.Loader",
|
||||
"Microsoft.Win32",
|
||||
// Whole type — no legitimate script use. Exposes process control
|
||||
// (Exit/FailFast), the host environment, and secrets via environment
|
||||
|
||||
@@ -40,7 +40,7 @@ public enum SessionValidationAction
|
||||
/// <summary>The session is idle-timed-out; reject + sign out.</summary>
|
||||
Reject,
|
||||
|
||||
/// <summary>The role mapping was refreshed; replace the principal and renew the cookie.</summary>
|
||||
/// <summary>The role mapping was refreshed; replace the principal. The HTTP adapter decides whether renewal is appropriate.</summary>
|
||||
Replace,
|
||||
}
|
||||
|
||||
@@ -56,9 +56,8 @@ public enum SessionValidationAction
|
||||
/// computed from the <see cref="JwtTokenService.LastActivityClaimType"/> anchor. This is
|
||||
/// the explicit, deterministic counterpart to the cookie middleware's
|
||||
/// <c>ExpireTimeSpan</c> + <c>SlidingExpiration</c> window — both use the SAME idle
|
||||
/// timeout value, so the explicit check never contradicts the cookie window. A
|
||||
/// not-timed-out session has its last-activity anchor advanced to "now" (genuine
|
||||
/// request = activity), mirroring the sliding renew.
|
||||
/// timeout value. Passive validation preserves the activity anchor. Explicit
|
||||
/// validated browser activity is also visible to live circuits through the session tracker.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Role refresh</b> (default <see cref="SecurityOptions.RoleRefreshThresholdMinutes"/>
|
||||
@@ -69,10 +68,9 @@ public enum SessionValidationAction
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Failure policy</b>: a refresh error (e.g. the mapper throws because the DB is
|
||||
/// unreachable) NEVER signs the user out and NEVER throws out of validation — it returns
|
||||
/// <see cref="SessionValidationResult.Keep"/>, mirroring the documented "LDAP failure:
|
||||
/// active sessions continue with current roles" stance. Only the explicit idle-timeout
|
||||
/// path rejects.
|
||||
/// unreachable) preserves current grants during ordinary cookie validation.
|
||||
/// Authoritative circuit mutation checks request fail-closed validation and propagate
|
||||
/// lookup errors, preventing a mutation from using grants that could not be refreshed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CookieSessionValidator
|
||||
@@ -81,6 +79,7 @@ public sealed class CookieSessionValidator
|
||||
private readonly SecurityOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<CookieSessionValidator> _logger;
|
||||
private readonly SessionActivityTracker? _activity;
|
||||
|
||||
/// <summary>Initializes the validator.</summary>
|
||||
/// <param name="roleMapper">The DB-backed group→role mapping seam (no LDAP) used for the mid-session refresh.</param>
|
||||
@@ -91,23 +90,24 @@ public sealed class CookieSessionValidator
|
||||
IGroupRoleMapper<string> roleMapper,
|
||||
IOptions<SecurityOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<CookieSessionValidator> logger)
|
||||
ILogger<CookieSessionValidator> logger, SessionActivityTracker? activity = null)
|
||||
{
|
||||
_roleMapper = roleMapper ?? throw new ArgumentNullException(nameof(roleMapper));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_activity = activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a cookie principal: enforces the idle timeout, then refreshes the
|
||||
/// role/scope claims from the stored LDAP groups when the role-refresh interval has
|
||||
/// elapsed. Never throws.
|
||||
/// elapsed or a caller requires a fresh mapping. Lookup errors propagate when failClosed is set.
|
||||
/// </summary>
|
||||
/// <param name="principal">The current cookie principal under validation.</param>
|
||||
/// <param name="ct">Cancellation token (the request-aborted token in the pipeline).</param>
|
||||
/// <returns>The action the caller must take and any replacement principal.</returns>
|
||||
public async Task<SessionValidationResult> ValidateAsync(ClaimsPrincipal? principal, CancellationToken ct = default)
|
||||
public async Task<SessionValidationResult> ValidateAsync(ClaimsPrincipal? principal, CancellationToken ct = default, bool recordActivity = true, bool forceRefresh = false, bool failClosed = false)
|
||||
{
|
||||
// An unauthenticated / null principal is left to the rest of the pipeline.
|
||||
if (principal?.Identity is not { IsAuthenticated: true })
|
||||
@@ -129,16 +129,16 @@ public sealed class CookieSessionValidator
|
||||
return SessionValidationResult.Reject;
|
||||
}
|
||||
|
||||
// 2) Role-mapping refresh — best-effort. Any failure keeps the existing session.
|
||||
// 2) Ordinary refresh keeps existing grants on failure; authoritative checks propagate it.
|
||||
try
|
||||
{
|
||||
var refreshed = await TryRefreshAsync(principal, now, ct).ConfigureAwait(false);
|
||||
var refreshed = await TryRefreshAsync(principal, now, ct, recordActivity, forceRefresh).ConfigureAwait(false);
|
||||
if (refreshed is not null)
|
||||
{
|
||||
return SessionValidationResult.Replace(refreshed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (!failClosed)
|
||||
{
|
||||
// SECURITY: never broaden access and never sign the user out on a transient
|
||||
// refresh fault — keep the existing principal (current roles) and swallow.
|
||||
@@ -168,16 +168,17 @@ public sealed class CookieSessionValidator
|
||||
return true;
|
||||
}
|
||||
|
||||
lastActivity = _activity?.LastActivity(principal) ?? lastActivity;
|
||||
return (now - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
||||
}
|
||||
|
||||
// Returns a rebuilt principal when the role-refresh interval has elapsed; null when
|
||||
// nothing changed. The principal is rebuilt via SessionClaimBuilder so its shape is
|
||||
// identical to /auth/login.
|
||||
private async Task<ClaimsPrincipal?> TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct)
|
||||
private async Task<ClaimsPrincipal?> TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct, bool recordActivity, bool forceRefresh)
|
||||
{
|
||||
var roleRefreshDue = IsRoleRefreshDue(principal, now);
|
||||
if (!roleRefreshDue)
|
||||
if (!roleRefreshDue && !forceRefresh)
|
||||
{
|
||||
// No mapping refresh due. We deliberately do NOT mint a new principal just to
|
||||
// advance LastActivity: the cookie middleware's SlidingExpiration already
|
||||
@@ -207,7 +208,20 @@ public sealed class CookieSessionValidator
|
||||
|
||||
// Rebuild identically to /auth/login, advancing BOTH anchors: the role-refresh
|
||||
// anchor (we just refreshed) and the idle anchor (this is a genuine request).
|
||||
return SessionClaimBuilder.Build(username, displayName, groups, scope, now);
|
||||
var rebuilt = SessionClaimBuilder.Build(username, displayName, groups, scope, now);
|
||||
var identity = (ClaimsIdentity)rebuilt.Identity!;
|
||||
var sessionId = principal.FindFirst(SessionActivityTracker.SessionIdClaim);
|
||||
identity.RemoveClaim(identity.FindFirst(SessionActivityTracker.SessionIdClaim)!);
|
||||
if (sessionId is not null) identity.AddClaim(new Claim(sessionId.Type, sessionId.Value));
|
||||
if (!recordActivity)
|
||||
{
|
||||
identity.RemoveClaim(identity.FindFirst(JwtTokenService.LastActivityClaimType)!);
|
||||
var lastActivity = _activity?.LastActivity(principal)?.ToString("o")
|
||||
?? principal.FindFirst(JwtTokenService.LastActivityClaimType)!.Value;
|
||||
identity.AddClaim(new Claim(JwtTokenService.LastActivityClaimType, lastActivity));
|
||||
}
|
||||
else _activity?.Record(rebuilt, now);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -68,6 +68,7 @@ public static class ServiceCollectionExtensions
|
||||
// is the wall clock. TryAddSingleton keeps the Host free to register its own.
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddScoped<CookieSessionValidator>();
|
||||
services.AddSingleton<SessionActivityTracker>();
|
||||
|
||||
// Audit Actor wiring (Phase 3): the user-facing inbound API audit path
|
||||
// sources AuditEvent.Actor from the authenticated principal via this
|
||||
@@ -157,6 +158,11 @@ public static class ServiceCollectionExtensions
|
||||
// throws — CookieSessionValidator.ValidateAsync swallows refresh faults and
|
||||
// keeps the session (mirrors "LDAP failure: active sessions continue").
|
||||
options.Events.OnValidatePrincipal = OnValidatePrincipalAsync;
|
||||
options.Events.OnCheckSlidingExpiration = context =>
|
||||
{
|
||||
if (IsPassiveSessionRequest(context.HttpContext)) context.ShouldRenew = false;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -218,13 +224,19 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Requests that cannot by themselves establish user activity.</summary>
|
||||
internal static bool IsPassiveSessionRequest(HttpContext context)
|
||||
=> context.Request.Path.StartsWithSegments("/auth/ping")
|
||||
|| context.Request.Path.StartsWithSegments("/auth/activity")
|
||||
|| context.Request.Path.StartsWithSegments("/_blazor");
|
||||
|
||||
/// <summary>
|
||||
/// The thin <see cref="CookieAuthenticationEvents.OnValidatePrincipal"/>
|
||||
/// adapter. It resolves the request-scoped <see cref="CookieSessionValidator"/>,
|
||||
/// asks it for a decision, and applies it to the cookie context:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="SessionValidationAction.Reject"/> → <see cref="CookieValidatePrincipalContext.RejectPrincipal"/> + sign out (idle-timeout — the only sign-out path).</item>
|
||||
/// <item><see cref="SessionValidationAction.Replace"/> → <see cref="CookieValidatePrincipalContext.ReplacePrincipal"/> + <c>ShouldRenew = true</c> (role mapping refreshed).</item>
|
||||
/// <item><see cref="SessionValidationAction.Replace"/> → <see cref="CookieValidatePrincipalContext.ReplacePrincipal"/> + renewal only for non-passive requests (role mapping refreshed).</item>
|
||||
/// <item><see cref="SessionValidationAction.Keep"/> → no-op (no refresh due, or a swallowed refresh fault).</item>
|
||||
/// </list>
|
||||
/// All logic lives in <see cref="CookieSessionValidator.ValidateAsync"/>, which never
|
||||
@@ -237,7 +249,7 @@ public static class ServiceCollectionExtensions
|
||||
var validator = context.HttpContext.RequestServices.GetRequiredService<CookieSessionValidator>();
|
||||
|
||||
var result = await validator
|
||||
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted)
|
||||
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted, recordActivity: !IsPassiveSessionRequest(context.HttpContext))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await ApplyValidationResultAsync(context, result).ConfigureAwait(false);
|
||||
@@ -272,7 +284,7 @@ public static class ServiceCollectionExtensions
|
||||
// Role mapping refreshed from stored groups — swap in the rebuilt
|
||||
// principal and re-issue the cookie so the new claims persist.
|
||||
context.ReplacePrincipal(result.Principal);
|
||||
context.ShouldRenew = true;
|
||||
context.ShouldRenew = !IsPassiveSessionRequest(context.HttpContext);
|
||||
break;
|
||||
|
||||
case SessionValidationAction.Keep:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Bounded, process-local activity shared by an HTTP cookie and its existing Blazor
|
||||
/// circuit. Only authenticated, validated interaction requests advance this clock.
|
||||
/// The protected cookie remains the durable anchor on restart or node change.
|
||||
/// </summary>
|
||||
public sealed class SessionActivityTracker
|
||||
{
|
||||
/// <summary>Opaque per-login identifier carried in the protected cookie.</summary>
|
||||
public const string SessionIdClaim = "zb:sessionid";
|
||||
private const int Capacity = 10_000;
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<string, DateTimeOffset> _activity = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns the newer of the protected cookie anchor and this node's observed activity.</summary>
|
||||
public DateTimeOffset? LastActivity(ClaimsPrincipal principal)
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(principal.FindFirst(JwtTokenService.LastActivityClaimType)?.Value, out var stamp))
|
||||
return null;
|
||||
var id = principal.FindFirst(SessionIdClaim)?.Value;
|
||||
lock (_sync)
|
||||
return id is not null && _activity.TryGetValue(id, out var seen) && seen > stamp ? seen : stamp;
|
||||
}
|
||||
|
||||
/// <summary>Records a validated interaction without changing another login's activity.</summary>
|
||||
public void Record(ClaimsPrincipal principal, DateTimeOffset now)
|
||||
{
|
||||
var id = principal.FindFirst(SessionIdClaim)?.Value;
|
||||
if (id is null || principal.Identity?.IsAuthenticated != true) return;
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_activity.ContainsKey(id) && _activity.Count >= Capacity)
|
||||
_activity.Remove(_activity.MinBy(p => p.Value).Key);
|
||||
if (!_activity.TryGetValue(id, out var previous) || now > previous)
|
||||
_activity[id] = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,11 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
/// refresh to "rebuild claims identically to /auth/login".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The claim shape is exactly what the login endpoint historically minted, plus the
|
||||
/// two additions:
|
||||
/// The login and refresh paths share the following identity and session claims:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ClaimTypes.NameIdentifier"/> — stable antiforgery identity.</item>
|
||||
/// <item><see cref="ClaimTypes.Name"/> — resolves <c>Identity.Name</c>.</item>
|
||||
/// <item><see cref="SessionActivityTracker.SessionIdClaim"/> — isolates activity to this login.</item>
|
||||
/// <item><see cref="JwtTokenService.DisplayNameClaimType"/> — human display name.</item>
|
||||
/// <item><see cref="JwtTokenService.UsernameClaimType"/> — canonical username.</item>
|
||||
/// <item><see cref="JwtTokenService.RoleClaimType"/> — one per mapped role.</item>
|
||||
@@ -64,13 +65,15 @@ public static class SessionClaimBuilder
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
// Antiforgery binds tokens to this stable identity, not mutable role/activity claims.
|
||||
new(ClaimTypes.NameIdentifier, username),
|
||||
new(ClaimTypes.Name, username),
|
||||
new(SessionActivityTracker.SessionIdClaim, Guid.NewGuid().ToString("N")),
|
||||
new(JwtTokenService.DisplayNameClaimType, displayName),
|
||||
new(JwtTokenService.UsernameClaimType, username),
|
||||
// Role-refresh anchor AND idle anchor are seeded from the same instant at
|
||||
// build time. They then diverge: OnValidatePrincipal advances LastActivity
|
||||
// on every request but only advances LastRoleRefresh when it actually
|
||||
// re-runs the mapping.
|
||||
// build time. Passive requests preserve LastActivity; accepted activity
|
||||
// advances it. LastRoleRefresh advances when the mapping is re-run.
|
||||
new(JwtTokenService.LastRoleRefreshClaimType, refreshStamp),
|
||||
new(JwtTokenService.LastActivityClaimType, refreshStamp),
|
||||
};
|
||||
|
||||
@@ -428,6 +428,10 @@ public class InstanceActor : ReceiveActor
|
||||
/// <inheritdoc />
|
||||
protected override void PostStop()
|
||||
{
|
||||
foreach (var connectionName in _tagsByConnection.Keys)
|
||||
_dclManager?.Tell(new UnsubscribeTagsRequest(
|
||||
Guid.NewGuid().ToString("N"), _instanceUniqueName, connectionName, DateTimeOffset.UtcNow), Self);
|
||||
|
||||
// Cancel any armed tag-subscribe watchdogs so they cannot fire after stop.
|
||||
foreach (var state in _tagSubscribeStates.Values)
|
||||
state.Timer?.Cancel();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user