Files
ScadaBridge/docs/requirements/Component-ManagementService.md
T
2026-09-09 22:56:36 -04:00

40 KiB
Raw Blame History

Component: Management Service

Purpose

The Management Service is an Akka.NET actor on the central cluster that provides programmatic access to all administrative operations. It exposes the same capabilities as the Central UI but through an actor-based interface, enabling the CLI (and potentially other tooling) to interact with the system without going through the web UI. The ManagementActor is reached in-process through ManagementActorHolder and is exposed via an authenticated HTTP Management API endpoint (POST /management) for external tools like the CLI.

Location

Central cluster only. The ManagementActor runs as a plain actor on every central node (not a cluster singleton). Because the actor is completely stateless — it holds no locks and no local state, delegating all work to repositories and services — running on all nodes improves availability without requiring coordination between instances. Either node can serve any request independently.

src/ZB.MOM.WW.ScadaBridge.ManagementService/

Responsibilities

  • Provide an actor-based interface to all administrative operations available in the Central UI.
  • Serve external tools through the authenticated HTTP Management API; actor references remain internal to the central host.
  • Expose an HTTP API endpoint (POST /management) that accepts JSON commands with Basic Auth, performs LDAP authentication and role resolution, and dispatches to the ManagementActor.
  • Validate and authorize all incoming commands using the authenticated user identity carried in message envelopes.
  • Delegate to the appropriate services and repositories for each operation.
  • Return structured response messages for all commands and queries.
  • Failover: The ManagementActor runs on all central nodes, so no actor-level failover is needed. Clients address the HTTP management endpoint through the configured active-node proxy. Site-bound commands use the gRPC pair transport and its explicit retry-safety contract.

Key Classes

ManagementActor

The central actor that receives and processes all management commands. Created at the well-known actor path (/user/management) and handed to ManagementActorHolder, which is how every in-process caller (notably ManagementEndpoints) reaches it. There is no receptionist registration or cross-cluster actor discovery.

ManagementEndpoints

Minimal API endpoint (POST /management) that serves as the HTTP interface to the ManagementActor. Handles Basic Auth decoding, LDAP authentication via LdapAuthService, role resolution via RoleMapper, command deserialization via ManagementCommandRegistry, and ManagementActor dispatch.

ManagementActorHolder

DI-registered singleton that holds the IActorRef for the ManagementActor. Set during actor registration in AkkaHostedService and injected into the HTTP endpoint handler.

ManagementCommandRegistry

Static registry mapping command names (e.g., "ListSites") to command types (e.g., ListSitesCommand). Built via reflection at startup. Used by the HTTP endpoint to deserialize JSON payloads into the correct command type.

Message Contracts

All request/response messages are defined in Commons under Messages/Management/. Messages follow the existing additive-only evolution rules for version compatibility. Every request message includes:

  • CorrelationId: Application-level correlation ID for request/response pairing.
  • AuthenticatedUser: The identity and roles of the user issuing the command (username, display name, roles, permitted sites). This is populated by the CLI from the user's authenticated session.

HTTP Management API

The Management Service also exposes a POST /management endpoint on the Central Host's web server. This provides an HTTP interface to the same ManagementActor, enabling the CLI (and other HTTP clients) to interact without Akka.NET dependencies.

Request format:

POST /management
Authorization: Basic base64(username:password)
Content-Type: application/json

{
  "command": "ListSites",
  "payload": {}
}

Response mapping:

  • ManagementSuccess → HTTP 200 with JSON body
  • ManagementError → HTTP 400 with { "error": "...", "code": "..." }
  • ManagementUnauthorized → HTTP 403 with { "error": "...", "code": "UNAUTHORIZED" }
  • Authentication failure → HTTP 401
  • Actor timeout → HTTP 504

The endpoint performs LDAP authentication and role resolution server-side, collapsing the CLI's previous two-step flow (ResolveRoles + actual command) into a single HTTP round-trip.

HTTP Audit API

In addition to /management, the Management Service exposes a dedicated REST surface for the centralized Audit Log component (#23). These endpoints live in AuditEndpoints.cs and bypass the ManagementActor because the query/export workloads are read-only, keyset-paged, and stream large result sets:

  • GET /api/audit/query — keyset-paged JSON query over the central AuditLog table. Authenticated via HTTP Basic Auth (shared with /management); gated on the OperationalAudit permission (Admin / Audit / AuditReadOnly roles).
  • GET /api/audit/export — server-side streaming bulk export (CSV or JSONL) of the filtered rows. Gated on the AuditExport permission (Admin / Audit).

Both endpoints honour any site-scope rules attached to the caller's audit role by intersecting the caller-supplied sourceSiteId filter with the user's PermittedSiteIds (out-of-scope requests yield HTTP 403). Permission denial returns HTTP 403 with the same envelope shape used by /management.

Filter parsing is strict on the enum dimensions (channel, kind, status; register row 99). Values are matched case-insensitively against the enum member names, and an unrecognised value returns HTTP 400 { error, code: "BAD_REQUEST" } naming the full valid set — it is not silently dropped. Silently dropping a filter would leave that dimension unconstrained and hand the caller a plausible, complete-looking page that does not answer the question they asked (the same failure row 88 fixed for deploy status). Ordinals are rejected: a filter is a name-based contract, so status=3 is an error even though Enum.TryParse would accept it and 3 is a real member. The correlationId/executionId/parentExecutionId/date dimensions stay lax (unparseable values are dropped).

This is the authoritative check for those three dimensions — the CLI deliberately carries no client-side vocabulary gate. It used to (AcceptOnlyFromAmong), but that check matches case-sensitively, so audit query --status delivered failed at the CLI before ever reaching a server that would have accepted it. The valid set now lives in the CLI's option help text only.

The CentralUI AuditLogPage drill-in parser and the CentralUI export endpoint it feeds deliberately keep the lax parse. They read a query string a human may have bookmarked or hand-edited, where a stale fragment must still render the page rather than fault it. Both contracts live side by side in AuditQueryParamParsers (ParseEnumList vs ParseEnumListStrict); do not reconcile them to one.

Message Groups

Templates

  • ListTemplates / GetTemplate: Query template definitions. ListTemplates supports additive offset paging via Skip / Take (arch-review P2): Take = null (the default) preserves the historical unpaged behaviour (unlimited), a set Take is clamped to 1..1000 and Skip floors at 0. Paging and projection are DB-side (WP2.5): the handler reads TemplateSummary rows (child collections reduced to counts, no script bodies) via GetTemplateSummariesAsync, instead of materialising every template's full five-Include child graph and then discarding all but one page in memory. A caller that needs a template's members already fetches it with GetTemplate.
  • CreateTemplate / UpdateTemplate / DeleteTemplate: Manage templates.
  • ValidateTemplate: Run on-demand pre-deployment validation (flattening, naming collisions, script compilation).
  • GetTemplateDiff: Compare deployed vs. template-derived configuration for an instance.

Template Folders

  • ListTemplateFolders: List all template folders (read-only; any authenticated user).
  • CreateTemplateFolder (Name, ParentFolderId?): Create a folder, optionally nested under a parent (Design role).
  • RenameTemplateFolder (FolderId, NewName): Rename a folder; enforces sibling uniqueness (Design role).
  • MoveTemplateFolder (FolderId, NewParentFolderId?): Move a folder to a new parent (or root); rejects cycles (Design role).
  • ReorderTemplateFolder (FolderId, Direction): Swap a folder's SortOrder with its previous (Up) or next (Down) sibling; reordering past either end is a no-op (Design role).
  • DeleteTemplateFolder (FolderId): Delete a folder; blocked if the folder contains any subfolders or templates (Design role).
  • MoveTemplateToFolder (TemplateId, NewFolderId?): Move a template into a folder, or to the root when null (Design role).

The whole folder surface is reachable from the CLI as template folder list|create|rename|move|reorder|delete as well as from the Central UI template tree.

Template Members

  • AddTemplateAttribute / UpdateTemplateAttribute / DeleteTemplateAttribute: Manage attributes on a template.
  • AddTemplateAlarm / UpdateTemplateAlarm / DeleteTemplateAlarm: Manage alarm definitions on a template.
  • AddTemplateNativeAlarmSource / UpdateTemplateNativeAlarmSource / DeleteTemplateNativeAlarmSource: Manage native alarm source bindings (the read-only OPC UA Alarms & Conditions / MxAccess Gateway mirror) on a template — Name, ConnectionName, SourceReference, optional ConditionFilter / Description, IsLocked. Mutations gated to the Design role; handlers write through ITemplateEngineRepository directly.
  • ListTemplateNativeAlarmSources (TemplateId): List a template's native alarm source bindings (read-only; any authenticated user).
  • AddTemplateScript / UpdateTemplateScript / DeleteTemplateScript: Manage scripts on a template.
  • AddTemplateComposition / DeleteTemplateComposition: Manage feature module compositions on a template.

Instances

  • ListInstances / GetInstance: Query instances, with filtering by site and area. ListInstances supports additive offset paging via Skip / Take (arch-review P2), applied after the in-memory site-scope filter so a site-scoped user never sees an out-of-scope instance surface into their page window; same Take = null unlimited default and 1..1000 clamp as ListTemplates. (ExportBundle still loads the full table internally — a logged scale follow-up, arch-review P2 deferred. QueryDeployments was closed by WP2.5, below.)
  • CreateInstance: Create a new instance from a template.
  • UpdateInstanceOverrides (SetInstanceOverrides): Set attribute overrides on an instance. Every requested attribute is validated against the instance's template up front (unknown or locked ⇒ the whole batch is rejected before any write), then the batch is applied as one bulk read of the existing override rows plus one commit (WP2.5). It previously ran K independent read-modify-commit cycles — each re-reading the instance, the template's attributes and the existing overrides, then committing and writing its own audit row — so the all-or-nothing promise was only as good as the pre-validation. It is now a real single-transaction apply, with one audit row summarising the batch.
  • MgmtDeploySite (SiteId, WP2.5): Bulk-deploy every deployable instance at one site. Site scope is enforced on the SITE (the command's target); every instance the batch touches belongs to that site by construction. Same Deployer authority as MgmtDeployInstance — it is N of those, not a new class of privilege — and registered as a long-running command so the caller does not time out mid-batch. Semantics, phasing and failure policy: see Component-DeploymentManager.md → Bulk site deployment.
  • SetInstanceAlarmOverride / DeleteInstanceAlarmOverride / ListInstanceAlarmOverrides: Manage per-instance computed-alarm overrides.
  • SetInstanceNativeAlarmSourceOverride / DeleteInstanceNativeAlarmSourceOverride: Retarget or clear a per-instance native alarm source binding, keyed by SourceCanonicalName — ConnectionNameOverride / SourceReferenceOverride / ConditionFilterOverride each apply only when non-null. Gated to the Deployment role.
  • ListInstanceNativeAlarmSourceOverrides (InstanceId): List an instance's native alarm source overrides (read-only).
  • SetInstanceBindings / BindDataConnections: Bind data connections to instance attributes.
  • AssignArea: Assign an instance to an area.
  • EnableInstance / DisableInstance / DeleteInstance: Instance lifecycle commands.

Sites

  • ListSites / GetSite: Query site definitions.
  • CreateSite / UpdateSite / DeleteSite: Manage site definitions.
  • ListAreas / CreateArea / UpdateArea / DeleteArea: Manage area hierarchies per site.
  • MoveArea (AreaId, NewParentAreaId?): Re-parent an area within its own site; a null NewParentAreaId moves it to the site root. Delegates every rule to AreaService.MoveAreaAsync — rejects self-parent, descendant-parent (cycle), cross-site parent, and sibling name collision at the target level; the service writes the "Move" audit row. Same any-of [Designer, Deployer] gate as the other area mutations. CLI: site area move.

Data Connections

  • ListDataConnections / GetDataConnection: Query data connection definitions. Both are site-scoped for a site-scoped non-Administrator caller — List filters an explicit SiteId through EnforceSiteScope and narrows an unfiltered list to the caller's permitted sites, rather than returning the fleet and nullifying Get's fail-closed gate (arch-review 2026-08-19 P6-4).
  • CreateDataConnection / UpdateDataConnection / DeleteDataConnection: Manage data connection definitions.
  • AssignDataConnectionToSite / UnassignDataConnectionFromSite: Manage site assignments.

Secret elision (arch-review C3). The PrimaryConfiguration/BackupConfiguration JSON blobs can embed OPC UA user and certificate passwords, yet List/Get are readable by any authenticated user. All command responses and audit afterState are projected through a secret-elided shape (DataConnectionPublicShape): secret-bearing values inside the config JSON are replaced with a redaction sentinel and a hasSecrets flag is surfaced (mirrors the SMTP/SMS HasCredentials pattern). Update merges the sentinel via ConfigSecretScrubber.MergeSentinels — a client that round-trips a scrubbed config keeps the stored secret rather than wiping it.

Deployments

  • DeployInstance: Deploy configuration to a specific instance (includes pre-deployment validation).

  • DeployArtifacts: Deploy system-wide artifacts (shared scripts, external system definitions, DB connections, data connections) to all sites or a specific site. The command's SiteId is honored (arch-review C2): a value routes to the single-site deploy path (ArtifactDeploymentService.DeployToSiteAsync), while null deploys fleet-wide (DeployToAllSitesAsync). Site scope is enforced — a site-scoped (non-Administrator) Deployer may only target a site within its PermittedSiteIds, and may not deploy fleet-wide (SiteId is null is rejected with SiteScopeViolationException → ManagementUnauthorized, since EnforceSiteScope(null) is a deliberate no-op); fleet-wide deployment requires a system-wide Deployer or Administrator.

  • DeploySite (MgmtDeploySite, WP2.5): Bulk-deploy every deployable instance at one site — see the Instances section above and Component-DeploymentManager.md.

  • GetDeploymentStatus: Query deployment status.

  • QueryDeployments (InstanceId?, Status?, Page, PageSize): List deployment records. All four arguments are honoured DB-side (WP2.5) and the result is a DeploymentQueryResult envelope — { deployments, totalCount, page, pageSize } over DeploymentListRow. The handler previously ignored Status, Page and PageSize entirely (the CLI had been sending them all along) and loaded the whole insert-only DeploymentRecords table; for a site-scoped user it additionally loaded every instance and intersected the two sets in memory. This read path also drives the opportunistic, rate-limited terminal-record retention sweep described in Component-DeploymentManager.md.

    One paged read, three drifts closed (deferred-work register rows 37, 86–88). This handler used to own a second repository query, QueryDeploymentSummariesAsync, alongside the Central UI page's QueryDeploymentListPageAsync. That query is deleted; both surfaces now go through QueryDeploymentListPageAsync with a DeploymentListFilter, and the DeploymentRecordSummary projection went with it (DeploymentListRow is the single row shape). The duplication had drifted into three behavioral differences, all resolved here:

    • Site scope is a SITE-ID filter, resolved by the query's own join to Instance (row 87). It used to be resolved in the handler by loading every instance in the system on every scoped query, purely to turn the site grant into an instance-id list — a second whole-table read, non-transactional with the one that mattered. An empty scope remains a real filter (a user permitted no sites sees nothing), never a no-op, and a permitted-site id that is not an integer is dropped rather than ignored — dropping can only narrow the grant, whereas discarding the grant would widen it to fleet-wide.
    • PageSize is bounded (row 87). A non-positive value used to mean unlimited all the way down to the repository, so --page-size 0 materialized the entire retention window into one response. It now means "unspecified" and takes DeploymentListPage.DefaultPageSize (50, the CLI's own default); every value is capped at DeploymentListPage.MaxPageSize (1000). totalCount travels with the bounded page precisely so a truncated page is distinguishable from a complete one.
    • An unrecognised Status is an ERROR, not "no filter" (row 88). --status Sucess used to fall back to unfiltered and return every status — a plausible, complete-looking answer to a question the caller never asked. It now returns ManagementError naming the valid values (Pending, InProgress, Success, Failed). Parsing stays case-insensitive; a numeric string is rejected too (Enum.TryParse accepts "7" and yields an undefined value, so the parse is guarded by Enum.IsDefined). Same loud-failure principle as register row 46.

    Administrator is system-wide on this path, as on every other site-scoped handler (EnforceSiteScope). The Central UI's SiteScopeService was claims-only and disagreed — an Administrator carrying SiteId claims saw the fleet here and only their sites on the Deployments page. The UI was corrected to match (register row 86); see Component-CentralUI.md.

Secured Writes (MxGateway, two-person)

The two-person authorization workflow for writes through the MxAccess Gateway. Backed by the central PendingSecuredWrite table; all four lifecycle events emit a best-effort Audit Log row sharing the row id as CorrelationId (see Component-AuditLog.md).

  • SubmitSecuredWriteCommand (Operator role): validates the target connection exists and uses the MxGateway protocol (rejected otherwise), then inserts a Pending row capturing site / connection / tag / typed value / operator + comment. Nothing is written yet.
  • ApproveSecuredWriteCommand (Verifier role): enforces no self-approval (VerifierUser ≠ OperatorUser, checked before any state change) and a compare-and-swap race guard (TryMarkApprovedAsync — exactly one concurrent approver flips Pending → Approved; the loser is rejected). On winning, it validates the value type, decodes the value (guarded — a bad value fails the row deterministically rather than leaving it stuck Approved), and relays a WriteTagRequest to the site MxGateway via the Communication Layer, recording the outcome as Executed or Failed.
  • RejectSecuredWriteCommand (Verifier role): marks a Pending row Rejected with the verifier's reason. Like approve, the transition is compare-and-swap guarded (TryMarkRejectedAsync — a conditional UPDATE … WHERE Status = 'Pending', so approve, reject and the TTL expiry can never both transition one row); a loser records nothing — no status write and no SecuredWriteReject audit row — and is answered with the status that actually won. The blind full-row update it replaced could land after a concurrent approve had won its own CAS and while that approve's multi-second WriteTagRequest relay was still in flight, stamping the row Rejected for a write that physically executed at the plant (arch-review 2026-08-19 P6-1). Winning the approve CAS therefore gives that leg exclusive ownership of the row, which is what makes its post-relay Executed/Failed update safe as an unconditional write.
  • ListSecuredWritesCommand (any of Operator / Verifier / Administrator): pending queue + terminal history, global or per-site, offset-paged via Skip / Take (arch-review P3; additive — the defaults Skip=0 / Take=200 preserve the historical unpaged wire behaviour). The handler clamps Take to 1..500 and Skip to >= 0, calls QueryAsync(Status, SiteId, skip, take) for the page and CountAsync(Status, SiteId) for the unpaged total, and returns a SecuredWriteListResult { Items, TotalCount } so a client can render a pager. Although read-only, the history table exposes process-sensitive tag values, so it is not open to any authenticated user — it is gated to the two Secured Write roles plus Administrator (any-of), aligning with the RequireSecuredWriteAccess Central UI policy (arch-review UA1).

Server-side pending TTL (arch-review S2). A Pending secured write is not valid indefinitely. Both ApproveSecuredWriteCommand and RejectSecuredWriteCommand call a shared TryExpireIfStaleAsync helper immediately after the Pending-status check: if the row's SubmittedAtUtc is older than the configured SecuredWritePendingTtl (ManagementServiceOptions, default 24 h), the helper atomically transitions it Pending → Expired via ISecuredWriteRepository.TryMarkExpiredAsync (compare-and-swap — no verifier recorded, VerifierUser stays NULL), emits a best-effort SecuredWriteExpire audit row (system actor, Discarded status — the closest terminal AuditStatus), and the command fails with an "expired" error. A stale setpoint therefore can never be approved or executed days later, and no WriteTagRequest is relayed. A non-positive TTL disables expiry. ListSecuredWritesCommand additionally runs an opportunistic sweep: on every read it walks the returned page and calls the same helper for each Pending row, so overdue writes surface as Expired the next time anyone views the queue — no dedicated expiry-timer singleton is needed because the ManagementActor already runs on every central node and the CAS makes concurrent multi-node sweeps idempotent.

External Systems

  • ListExternalSystems / GetExternalSystem: Query external system definitions.
  • CreateExternalSystem / UpdateExternalSystem / DeleteExternalSystem: Manage external system definitions.

Secret elision (arch-review C3). The AuthConfiguration blob holds API keys / Basic credentials. All command responses and audit afterState are projected through ExternalSystemPublicShape, which drops AuthConfiguration entirely and surfaces only a hasAuthConfiguration presence flag. Update is preserve-if-null: an omitted (null) AuthConfiguration leaves the stored secret intact (mirrors the SMTP/SMS credential rule).

Notifications

  • ListNotificationLists / GetNotificationList: Query notification lists.
  • CreateNotificationList / UpdateNotificationList / DeleteNotificationList: Manage notification lists and recipients.
  • GetSmtpConfig / UpdateSmtpConfig: Query and update SMTP configuration.

Security (LDAP & API Keys)

  • ListApiKeys / CreateApiKey / UpdateApiKey / EnableApiKey / DisableApiKey / DeleteApiKey: Manage API keys.
  • ListRoleMappings / CreateRoleMapping / UpdateRoleMapping / DeleteRoleMapping: Manage LDAP group-to-role mappings.
  • ListScopeRules / AddScopeRule / DeleteScopeRule: Manage site scope rules on role mappings.

Audit Log

  • QueryAuditLog: Legacy configuration-change audit query (filtered by entity type, user, date range, etc.) routed through /management. Gated to the Admin role; superseded for the centralized Audit Log component (#23) by the dedicated /api/audit/* REST endpoints described below.

Transport (Bundle Import / Export)

  • ExportBundle: Build an encrypted bundle (templates, system artifacts, central-only configuration). Gated to the Design role. Returns base64-encoded bundle bytes plus a byte count.
  • PreviewBundle: Unlock and inspect a previously uploaded bundle session, returning the per-entity preview (adds, modifies, identicals, blockers). Gated to the Admin role.
  • ImportBundle: Apply a previewed bundle with per-conflict resolutions inside a single audit-correlated session. Gated to the Admin role.

Shared Scripts

  • ListSharedScripts / GetSharedScript: Query shared script definitions.
  • CreateSharedScript / UpdateSharedScript / DeleteSharedScript: Manage shared scripts.

Database Connections

  • ListDatabaseConnections / GetDatabaseConnection: Query database connection definitions.
  • CreateDatabaseConnection / UpdateDatabaseConnection / DeleteDatabaseConnection: Manage database connections.

Secret elision (arch-review C3). The ADO.NET ConnectionString can embed the SQL password. List/Get responses are projected through DatabaseConnectionPublicShape ({ id, name, hasConnectionString }) — the connection string itself is never emitted. UpdateDatabaseConnectionDefCommand.ConnectionString is nullable and preserve-if-null: an omitted (null) value leaves the stored connection string intact, so a round-trip of the elided shape does not wipe the secret.

Inbound API Methods

  • ListApiMethods / GetApiMethod: Query inbound API method definitions.
  • CreateApiMethod / UpdateApiMethod / DeleteApiMethod: Manage inbound API methods.

Health

  • GetHealthSummary: Query current health status of all sites.
  • GetSiteHealth: Query detailed health for a specific site.

Remote Queries

  • QuerySiteEventLog: Query site event log entries from a remote site (routed via communication layer). Supports date range, keyword search, and pagination.
  • QueryParkedMessages: Query parked (dead-letter) messages at a remote site (routed via communication layer). Supports pagination.
  • DebugSnapshot: Request a one-shot snapshot of attribute values and alarm states for a running instance. Resolves the instance's site from the config DB and routes via the communication layer. Uses 30s QueryTimeout.
  • BrowseNodeCommand: Browse an OPC UA / MxGateway connection's address space one level at a time; supports a BrowseNext continuation token (paging) and returns per-node type-info for OPC UA Variables. Actor-dispatched — the handler requires the command's SiteIdentifier, enforces site-scope, then relays via CommunicationService.BrowseNodeAsync (arch-review C4, CLI/UI parity). (Design role.)
  • SearchAddressSpaceCommand: Bounded recursive address-space search (depth + result caps, case-insensitive substring) against an OPC UA connection. Actor-dispatched, same SiteIdentifier-required + site-scope pattern, relayed via CommunicationService.SearchAddressSpaceAsync (arch-review C4). (Design role.)
  • VerifyEndpointCommand: Ask a site to probe an OPC UA endpoint (temporary client, short timeout) and report success / typed failure / a captured-but-untrusted server cert. Read-only — never trusts. Actor-dispatched, SiteIdentifier-required + site-scope, relayed via CommunicationService.VerifyEndpointAsync (arch-review C4). (Design role — runs inside the Admin-gated connection editor.)
  • TrustServerCertCommand / RemoveServerCertCommand / ListServerCertsCommand: Manage the site's OPC UA trusted-peer PKI store; Trust/Remove broadcast to both site nodes (see Component-SiteRuntime.md). Actor-dispatched and Admin-gated at the actor (arch-review C4) — each handler requires the command's SiteIdentifier, enforces site-scope, then relays via CommunicationService.TrustServerCertAsync / RemoveServerCertAsync / ListServerCertsAsync; the site-side reconcile across both nodes is owned by the site CertStoreActor. (Admin role.)

Flow Debugger

All four commands return FlowDebugRunResult: the chain root execution ID, completion/error metadata, audit events, timestamps, and independent event/log truncation flags.

Command Behaviour Required role
FlowDebugRunMethodCommand Validates an inbound API method's parameters and runs it with tracing; routed calls enforce the caller's site scope. Deployer
FlowDebugRunValueChangeCommand Writes an instance attribute through the real site path and polls for the triggered execution tree. Deployer
FlowDebugRunScriptCommand Resolves the effective script; writes its trigger attribute for write-trigger kinds, otherwise invokes it directly with validated parameters. Deployer
FlowDebugReplayCommand Reads the stored execution tree for any execution ID, enforcing caller scope and retaining the repository's chain anchor. Any of Deployer, Administrator, Viewer

Live commands require the active central node and return STANDBY_NODE elsewhere. They perform real script/device operations and write operator-attributed audit roots. One cancellation budget starts at runner entry, covers lookups, execution/write, audit and trace pulls, and reserves five seconds below LongRunningCommandTimeout for the reply. Configuration must exceed that grace. Write-trigger waits are clamped to 1–295 seconds and the remaining total budget. Polling drains all (OccurredAtUtc, EventId) keyset pages in execution-ID batches of at most 64. Each subsequent poll intentionally rescans those batches: closing rows are inserted at completion but stamped with their start time, so a persistent timestamp cursor would miss them. The root is a fresh execution GUID; scans use no timestamp lower bound and do not require central/site clock agreement. This removes fixed-page starvation but deliberately retransfers known rows. Site reads use partial valid-JSON execution/parent-expression indexes; an indexed union of keys deduplicates both matches and limits the page before loading payloads. Malformed legacy details are excluded from trace matching without preventing index initialization. Discovery retains deduplicated rows for the bounded command duration, so working memory grows with the traversed tree; only the returned reply has a fixed collector cap. Inline results use FlowTraceCollector count/byte caps and expose truncation. Replay also has repository count/UTF-8 bounds; it does not execute scripts or write to devices.

Authorization

Every incoming message carries the authenticated user's identity and roles. The ManagementActor enforces the same role-based authorization rules as the Central UI:

  • Admin role required for: site management, API key management, role mapping management, scope rule management, system configuration.
  • Design role required for: template authoring (including template member management: attributes, alarms, native alarm sources, scripts, compositions), shared scripts, external system definitions, database connection definitions, notification lists, inbound API method definitions.
  • Deployment role required for: instance management (including instance alarm overrides and native alarm source overrides), deployments, debug view, debug snapshot, parked message queries, site event log queries. Site scoping is enforced for site-scoped Deployment users.
  • Any of Design / Deployment required for: area management (CreateAreaCommand / UpdateAreaCommand / MoveAreaCommand / DeleteAreaCommand). Areas are authored both in the Designer tooling and inside the Central UI Deployment Topology workflow (Topology.razor is RequireDeployment), so either role qualifies — enforced as an any-of gate (arch-review C6). Administrator is not implicitly included.
  • Operator role required for: submitting a secured write (SubmitSecuredWriteCommand).
  • Verifier role required for: approving / rejecting a secured write (ApproveSecuredWriteCommand / RejectSecuredWriteCommand). The no-self-approval rule (Operator ≠ Verifier) is enforced in the handler, independent of the role check.
  • Any of Operator / Verifier / Administrator required for: listing / querying secured writes (ListSecuredWritesCommand). Read-only, but the history exposes process-sensitive tag values, so it is gated any-of rather than open to every authenticated user (arch-review UA1). Enforced as an any-of gate (GetRequiredRoles returns the permitted set; the caller must hold at least one).
  • Admin role additionally required for: server-certificate trust/remove/list (TrustServerCertCommand / RemoveServerCertCommand / ListServerCertsCommand).
  • Read-only access (any authenticated role): health summary, health site, site event log queries, parked message queries.

Unauthorized commands receive an Unauthorized response message. Failed authorization attempts are not audit logged (consistent with existing behavior).

Site-scope gate: fail-closed on an unresolvable target

The site-scope helpers resolve the command's target (instance id, site id, site identifier, data-connection id) and check the resolved site against the caller's PermittedSiteIds. A target that cannot be resolved is DENIED for a site-scoped caller — SiteScopeViolationException → ManagementUnauthorized, exactly as if it were out of scope.

The gate used to be fail-open here (deferred-work register row 98). The resolve-then-check shape was written as if (target != null) EnforceSiteScope(...), so a null resolve skipped the check entirely and a site-scoped caller fell through into the handler body — reaching HandleGetInstance/HandleGetSite/HandleGetDataConnection's null answer, or a handler fault, for any id in the system. The rule is now: a target that cannot be PROVEN to sit inside the caller's scope is treated as outside it.

Two properties are load-bearing:

  • System-wide callers are unaffected. Administrator, and a Deployer carrying no site grants, return at the guards above the lookup — they were never scoped, so they still reach the handler and still receive its ordinary "not found" / null answer for a missing id. Only a scoped caller is denied.
  • The denial does not leak existence. A scoped caller gets the identical ManagementUnauthorized shape for "does not exist" and for "exists at a site you may not see", and the message never states which. Otherwise the gate would be an id-enumeration oracle over the whole fleet — which is what the fail-open behaviour was, since GetInstance/GetSite/GetDataConnection answered success-with-null for a nonexistent id and Unauthorized for an out-of-scope one.

The shared denial arm is DenyUnresolvedTargetIfScoped; EnforceSiteScopeForInstance, EnforceSiteScopeForIdentifier and the three read handlers that resolve their own target all route through it.

Service Dependencies (DI)

The ManagementActor receives the following services and repositories via DI (injected through the actor's constructor or via a service provider):

  • ITemplateEngineRepository / TemplateService — Template operations.
  • InstanceService — Instance lifecycle and configuration.
  • ISiteRepository — Site definitions and area management.
  • IDeploymentManagerRepository / DeploymentService — Deployment pipeline operations.
  • ArtifactDeploymentService — System-wide artifact deployment.
  • IExternalSystemRepository — External system definitions.
  • INotificationRepository — Notification lists and SMTP config.
  • ISecurityRepository — API keys and LDAP role mappings.
  • ISecuredWriteRepository — Pending/decided secured writes (the two-person MxGateway write workflow).
  • IAuditLogRepository — Best-effort central direct-write of secured-write lifecycle audit rows.
  • IInboundApiRepository — Inbound API method definitions.
  • ISharedScriptRepository / SharedScriptService — Shared script definitions.
  • IDatabaseConnectionRepository — Database connection definitions.
  • ICentralHealthAggregator — Health status aggregation.
  • CommunicationService — Central-site communication for deployment and remote queries.

Configuration

Section Options Class Contents
ScadaBridge:ManagementService ManagementServiceOptions CommandTimeout (TimeSpan, default 30 s) — Ask timeout the HTTP endpoint applies when forwarding to the ManagementActor. A non-positive configured value falls back to the 30 s default.
LongRunningCommandTimeout (TimeSpan, default 5 min) — Ask timeout applied to long-running commands (ImportBundle, PreviewBundle, ExportBundle, MgmtDeployArtifacts, MgmtDeployInstance, MgmtDeploySite); all other commands use CommandTimeout. A non-positive configured value falls back to the 5 min default.
SecuredWritePendingTtl (TimeSpan, default 24 h) — age after which a Pending secured write is transitioned to Expired and can no longer be approved/executed; enforced at approve/reject and swept opportunistically on list. A non-positive value disables expiry (arch-review S2).

Dependencies

  • Commons: Message contracts (Messages/Management/), shared types, repository interfaces.
  • Configuration Database (MS SQL): All queries and mutations go through repositories backed by EF Core.
  • Configuration Database (via IAuditService): All mutating operations are audit logged through the existing transactional audit mechanism.
  • Communication Layer: Deployment commands and remote queries (parked messages, event logs) are routed to sites via Communication.
  • Security & Auth: Authorization rules are enforced on every command using the authenticated user identity from the message envelope.
  • Cluster Infrastructure: ManagementActor runs on all central nodes within the shared central ActorSystem.
  • All service components: The ManagementActor delegates to the same services used by the Central UI — Template Engine, Deployment Manager, etc.

Interactions

  • CLI: The primary consumer. Connects via the HTTP Management API (POST /management) and sends commands as JSON with Basic Auth credentials.
  • Host: Creates the ManagementActor and its holder, and maps the authenticated HTTP endpoint on central nodes during startup.
  • Central UI: Shares the same underlying services and repositories. The ManagementActor and Central UI are parallel interfaces to the same operations.
  • 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.

Scoped and bounded execution-chain reads

Audit tree HTTP reads and Flow Debugger Replay pass the authenticated caller's permitted source-site set into the repository. Administrator and system-wide grants omit the filter; scoped reads retain central rows with null SourceSiteId while excluding other sites from ancestors, descendants and aggregates. Replay retains root identity separately from payload rows and propagates truncation truthfully. It returns at most500 events and reserves envelope overhead within a1MiB UTF-8 budget. The Central UI direct tree-query facade applies the same current-scope policy.