Files
ScadaBridge/docs/requirements/Component-ManagementService.md
T
Joseph Doherty f33f25a10b fix(auth+cli): fail-closed site-scope gate (row 98); case-insensitive audit filter values with a strict server-side check (row 99)
Register row 98 — the site-scope authorization gate was FAIL-OPEN on an
unresolvable target. 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 passed the gate for any id in the
system. Authorization now fails CLOSED: a target that cannot be PROVEN to sit
inside the caller's scope is treated as outside it.

The defect turned out to have THREE more instances than the register recorded.
Besides `EnforceSiteScopeForInstance` and `EnforceSiteScopeForIdentifier`, the
same inline shape sat in `HandleGetInstance`, `HandleGetSite` and
`HandleGetDataConnection` — read handlers that resolve their own target and so
never call the helpers. Those were the sharper half of the bug: they answered
success-with-null for a nonexistent id and Unauthorized for an out-of-scope
one, making the pair an id-enumeration oracle over the whole fleet. All five
now route through one shared `DenyUnresolvedTargetIfScoped` arm.

Two properties are load-bearing and pinned by tests:
  - System-wide callers (Administrator, or a Deployer with no site grants) are
    untouched — they return at the guards above the lookup and keep the
    handler's ordinary "not found" / null answer.
  - The denial is indistinguishable from an out-of-scope denial: same
    ManagementUnauthorized shape, and a message that never states whether the
    target exists.

Register row 99 — CLI `audit query --status delivered` failed client-side while
the server would have accepted it: `AcceptOnlyFromAmong` matches ordinally
against the capitalised literals. Same treatment row 88 gave `deploy status` —
the client-side gate is removed, the valid set moves into the option help text,
and the server becomes the single authoritative check.

That required ADDING the server check, not just relying on one: the shared
audit filter parser was LAX, silently dropping an unrecognised value and
leaving the dimension unconstrained — so removing the CLI gate alone would have
turned a loud client-side failure into a plausible, complete-looking page that
did not answer the question asked. New `ParseEnumListStrict` (case-insensitive,
rejects ordinals — `Enum.TryParse` accepts "3", and a filter is a name-based
contract) backs the ManagementService /api/audit/query + /api/audit/export
endpoints, which now return 400 BAD_REQUEST naming the valid set.

Other AcceptOnlyFromAmong sites surveyed in the audit group: `--channel`,
`--kind`, `--status` on BOTH `query` and `export` (6 uses) plus `export
--format` (rejected `--format CSV` though the server lowercases it) — all seven
fixed here. Deliberately left alone: `template folder reorder --direction`
(pure client-side literal, no server-side parse to defer to) and the global
`--format json|table` (CLI-local output selection, never sent to a server).

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, where a stale fragment must still render the page. Both
contracts now live side by side in AuditQueryParamParsers and are documented as
non-reconcilable.

Docs: Component-ManagementService.md gains a "Site-scope gate: fail-closed on
an unresolvable target" section and a strict-filter note on the audit
endpoints; Component-Security.md records the fail-closed shape under Deployer
site scoping; the CLI README marks the audit filter values case-insensitive and
states the no-silent-drop / no-ordinals contract.

Tests: 5 new ManagementActorTests fail before the row-98 fix (verified by
reverting ManagementActor.cs) and cover scoped+nonexistent -> denied,
system-wide+nonexistent -> unchanged (both Administrator and no-grant Deployer),
scoped+in-scope -> allowed, and the indistinguishability property. 21 CLI tests
fail before the row-99 fix. Four pre-existing tests that pinned the removed
client-side gate were rewritten to pin the new contract.
2026-08-17 06:05:29 -04:00

36 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 registers with ClusterClientReceptionist for cross-cluster access and is also exposed via an 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.
  • Register with Akka.NET ClusterClientReceptionist so external tools (CLI) can discover and communicate with it via ClusterClient.
  • 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. If one node goes down, the ClusterClient transparently routes to the ManagementActor on the remaining node.

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. It is not advertised via ClusterClientReceptionist — see the note under "ManagementActor and ClusterClient" in Component-Communication.

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.

ClusterClientReceptionist Registration

The ManagementActor registers itself with ClusterClientReceptionist at startup. This allows external processes using ClusterClient to send messages to the ManagementActor without joining the Akka.NET cluster as a full member. The receptionist advertises the actor under its well-known path.

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.
  • 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.
  • 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.)

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; ClusterClientReceptionist requires cluster membership.
  • 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: Registers the ManagementActor and ClusterClientReceptionist 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.