# Admin Web UI — OtOpcUa v2 > **Status**: DRAFT — companion to `plan.md` §4 and `config-db-schema.md`. Defines the Blazor Server admin app for managing the central config DB. > > **Branch**: `v2` > **Created**: 2026-04-17 ## Scope This document covers the **OtOpcUa Admin** web app — the operator-facing UI for managing fleet configuration. It owns every write to the central config DB; OtOpcUa nodes are read-only consumers. Out of scope here: - Per-node operator dashboards (status, alarm acks for runtime concerns) — that's the existing Status Dashboard, deployed alongside each node, not the Admin app - Driver-specific config screens — these are deferred to each driver's implementation phase per decision #27, and each driver doc is responsible for sketching its config UI surface - Authentication of the OPC UA endpoint itself — covered by `Security.md` (LDAP) ## Tech Stack **Aligned with ScadaLink CentralUI** (`scadalink-design/src/ScadaLink.CentralUI`) — operators using both apps see the same login screen, same sidebar, same component vocabulary. Same patterns, same aesthetic. | Component | Choice | Reason | |-----------|--------|--------| | Framework | **Blazor Server** (.NET 10 Razor Components, `AddInteractiveServerComponents`) | Same as ScadaLink; real-time UI without separate SPA build; SignalR built-in for live cluster status | | Hosting | Co-deploy with central DB by default; standalone option | Most deployments run Admin on the same machine as MSSQL; large fleets can split | | Auth | **LDAP bind via `LdapAuthService` (sibling of `ScadaLink.Security`) + cookie auth + `JwtTokenService` for API tokens** | Direct parity with ScadaLink — same login form, same cookie scheme, same claim shape, same `RoleMapper` pattern. Operators authenticated to one app feel at home in the other | | DB access | EF Core (same `Configuration` project that nodes use) | Schema versioning lives in one place | | Real-time | SignalR (Blazor Server's underlying transport) | Live updates on `ClusterNodeGenerationState` and crash-loop alerts | | Styling | **Bootstrap 5** vendored under `wwwroot/lib/bootstrap/` | Direct parity with ScadaLink; standard component vocabulary (card, table, alert, btn, form-control, modal); no third-party Blazor-component-library dependency | | Shared components | `DataTable`, `ConfirmDialog`, `LoadingSpinner`, `ToastNotification`, `TimestampDisplay`, `RedirectToLogin`, `NotAuthorizedView` | Same set as ScadaLink CentralUI; copy structurally so cross-app feel is identical | | Reconnect overlay | Custom Bootstrap modal triggered on `Blazor` SignalR disconnect | Same pattern as ScadaLink — modal appears on connection loss, dismisses on reconnect | ### Code organization Mirror ScadaLink's layout exactly: ``` src/ ZB.MOM.WW.OtOpcUa.Admin/ # Razor Components project (.NET 10) Auth/ AuthEndpoints.cs # /auth/login, /auth/logout, /auth/token CookieAuthenticationStateProvider.cs # bridges cookie auth to Blazor Components/ Layout/ MainLayout.razor # dark sidebar + light main flex layout NavMenu.razor # role-gated nav sections Pages/ Login.razor # server-rendered HTML form POSTing to /auth/login Dashboard.razor # default landing Clusters/ Generations/ Credentials/ Audit/ Shared/ DataTable.razor # paged/sortable/filterable table (verbatim from ScadaLink) ConfirmDialog.razor LoadingSpinner.razor ToastNotification.razor TimestampDisplay.razor RedirectToLogin.razor NotAuthorizedView.razor EndpointExtensions.cs # MapAuthEndpoints + role policies ServiceCollectionExtensions.cs # AddCentralAdmin ZB.MOM.WW.OtOpcUa.Admin.Security/ # LDAP + role mapping + JWT (sibling of ScadaLink.Security) ``` The `Admin.Security` project carries `LdapAuthService`, `RoleMapper`, `JwtTokenService`, `AuthorizationPolicies`. If it ever makes sense to consolidate with ScadaLink's identical project, lift to a shared internal NuGet — out of scope for v2.0 to keep OtOpcUa decoupled from ScadaLink's release cycle. ## Authentication & Authorization ### Operator authentication **Identical pattern to ScadaLink CentralUI.** Operators log in via LDAP bind against the GLAuth server. The login flow is a server-rendered HTML form POSTing to `/auth/login` (NOT a Blazor interactive form — `data-enhance="false"` to disable Blazor enhanced navigation), handled by a minimal-API endpoint that: 1. Reads `username` / `password` from form 2. Calls `LdapAuthService.AuthenticateAsync(username, password)` — performs LDAP bind, returns `Username`, `DisplayName`, `Groups` 3. Calls `RoleMapper.MapGroupsToRolesAsync(groups)` — translates LDAP groups → application roles + cluster-scope set 4. Builds `ClaimsIdentity` with `Name`, `DisplayName`, `Username`, `Role` (multiple), `ClusterId` scope claims (multiple, when not system-wide) 5. `HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, ...)` with `IsPersistent = true`, `ExpiresUtc = +30 min` (sliding) 6. Redirects to `/` 7. On failure, redirects to `/login?error={URL-encoded message}` A parallel `/auth/token` endpoint returns a JWT for API clients (CLI tooling, scripts) — same auth, different transport. Symmetric with ScadaLink's pattern. `CookieAuthenticationStateProvider` bridges the cookie principal to Blazor's `AuthenticationStateProvider` so `` and `[Authorize]` work in components. ### LDAP group → role mapping | LDAP group | Admin role | Capabilities | |------------|------------|--------------| | `OtOpcUaAdmins` | `FleetAdmin` | Everything: cluster CRUD, node CRUD, credential management, publish/rollback any cluster | | `OtOpcUaConfigEditors` | `ConfigEditor` | Edit drafts and publish for assigned clusters; cannot create/delete clusters or manage credentials | | `OtOpcUaViewers` | `ReadOnly` | View-only access to all clusters and generations; cannot edit drafts or publish | `AuthorizationPolicies` constants (mirrors ScadaLink): `RequireFleetAdmin`, `RequireConfigEditor`, `RequireReadOnly`. `` gates nav menu sections and page-level access. ### Cluster-scoped grants (lifted from v2.1 to v2.0) Because ScadaLink already has the site-scoped grant pattern (`PermittedSiteIds` claim, `IsSystemWideDeployment` flag), we get cluster-scoped grants essentially for free in v2.0 by mirroring it: - A `ConfigEditor` user mapped to LDAP group `OtOpcUaConfigEditors-LINE3` is granted `ConfigEditor` role + `ClusterId=LINE3-OPCUA` scope claim only - The `RoleMapper` reads a small `LdapGroupRoleMapping` table (Group → Role, Group → ClusterId scope) configured by `FleetAdmin` via the Admin UI - All cluster-scoped pages check both role AND `ClusterId` scope claim before showing edit affordances System-wide users (no `ClusterId` scope claims, `IsSystemWideDeployment = true`) see every cluster. ### Bootstrap (first-run) Same as ScadaLink: a local-admin login configured in `appsettings.json` (or a local certificate-authenticated user) bootstraps the first `OtOpcUaAdmins` LDAP group binding before LDAP-only access takes over. Documented as a one-time setup step. ### Audit Every write operation goes through `sp_*` procs that log to `ConfigAuditLog` with the operator's principal. The Admin UI also logs view-only actions (page navigation, generation diff views) to a separate UI access log for compliance. ## Visual Design — Direct Parity with ScadaLink Every visual element is lifted from ScadaLink CentralUI's design system to ensure cross-app consistency. Concrete specs: ### Layout - **Flex layout**: `
` containing `` (sidebar) and `
` (content) - **Sidebar**: 220px fixed width (`min-width: 220px; max-width: 220px`), full viewport height (`min-height: 100vh`), background `#212529` (Bootstrap dark) - **Main background**: `#f8f9fa` (Bootstrap light) - **Brand**: "OtOpcUa" in white bold (font-size: 1.1rem, padding 1rem, border-bottom `1px solid #343a40`) at top of sidebar - **Nav links**: color `#adb5bd`, padding `0.4rem 1rem`, font-size `0.9rem`. Hover: white text, background `#343a40`. Active: white text, background `#0d6efd` (Bootstrap primary) - **Section headers** ("Admin", "Configuration", "Monitoring"): color `#6c757d`, uppercase, font-size `0.75rem`, font-weight `600`, letter-spacing `0.05em`, padding `0.75rem 1rem 0.25rem` - **User strip** at bottom of sidebar: display name (text-light small) + Sign Out button (`btn-outline-light btn-sm`), separated from nav by `border-top border-secondary` ### Login page Verbatim structure from ScadaLink's `Login.razor`: ```razor

OtOpcUa

@if (!string.IsNullOrEmpty(ErrorMessage)) { }

Authenticate with your organization's LDAP credentials.

``` Exact same dimensions, exact same copy pattern, only the brand name differs. ### Reconnection overlay Same SignalR-disconnect modal as ScadaLink — `#reconnect-modal` overlay (`rgba(0,0,0,0.5)` backdrop, centered white card with `spinner-border text-primary`, "Connection Lost" heading, "Attempting to reconnect to the server. Please wait..." body). Listens for `Blazor.addEventListener('enhancedload')` to dismiss on reconnect. Lifted from ScadaLink's `App.razor` inline styles. ### Shared components — direct copies All seven shared components from ScadaLink CentralUI are copied verbatim into our `Components/Shared/`: | Component | Use | |-----------|-----| | `DataTable.razor` | Sortable, filterable, paged table — used for tags, generations, audit log, cluster list | | `ConfirmDialog.razor` | Modal confirmation for destructive actions (publish, rollback, discard draft, disable credential) | | `LoadingSpinner.razor` | Standard spinner for in-flight DB operations | | `ToastNotification.razor` | Transient success/error toasts for non-modal feedback | | `TimestampDisplay.razor` | Consistent UTC + relative-time rendering ("3 minutes ago") | | `RedirectToLogin.razor` | Component used by pages requiring auth — server-side redirect to `/login?returnUrl=...` | | `NotAuthorizedView.razor` | Standard "you don't have permission for this action" view, shown by `` Not authorized branch | If we discover an Admin-specific component need, add it to our Shared folder rather than diverging from ScadaLink's set. ## Information Architecture ``` / Fleet Overview (default landing) /clusters Cluster list /clusters/{ClusterId} Cluster detail /clusters/{ClusterId}/nodes/{NodeId} Node detail /clusters/{ClusterId}/draft Draft editor (drivers/devices/tags) /clusters/{ClusterId}/draft/diff Draft vs current diff viewer /clusters/{ClusterId}/generations Generation history /clusters/{ClusterId}/generations/{Id} Generation detail (read-only view of any generation) /clusters/{ClusterId}/audit Audit log filtered to this cluster /credentials Credential management (FleetAdmin only) /audit Fleet-wide audit log /admin/users Admin role assignments (FleetAdmin only) ``` ## Core Pages ### Fleet Overview (`/`) Single-page summary intended as the operator landing page. - **Cluster cards**, one per `ServerCluster`, showing: - Cluster name, site, redundancy mode, node count - Per-node status: online/offline (from `ClusterNodeGenerationState.LastSeenAt`), current generation, RedundancyRole, ServiceLevel (last reported) - Drift indicator: red if 2-node cluster's nodes are on different generations, amber if mid-apply, green if converged - **Active alerts** strip (top of page): - Sticky crash-loop circuit alerts (per `driver-stability.md`) - Stragglers: nodes that haven't applied the latest published generation within 5 min - Failed applies (`LastAppliedStatus = 'Failed'`) - **Recent activity**: last 20 events from `ConfigAuditLog` across the fleet - **Search bar** at top: jump to any cluster, node, tag, or driver instance by name Refresh: SignalR push for status changes; full reload every 30 s as a safety net. ### Cluster Detail (`/clusters/{ClusterId}`) Tabbed view for one cluster. **Tabs:** 1. **Overview** — cluster metadata (name, site, redundancy mode, namespace URI), node table with online/offline/role/generation/last-applied-status, current published generation summary, draft status (none / in progress / ready to publish) 2. **Drivers** — table of `DriverInstance` rows in the *current published* generation, with per-row navigation to driver-specific config screens. "Edit in draft" button creates or opens the cluster's draft. 3. **Devices** — table of `Device` rows (where applicable), grouped by `DriverInstance` 4. **Tags** — paged, filterable table of all tags. Filters: driver, device, folder path, name pattern, data type. Bulk operations toolbar: export to CSV, import from CSV (validated against active draft). 5. **Generations** — generation history list (see Generation History page) 6. **Audit** — filtered audit log The Drivers/Devices/Tags tabs are **read-only views** of the published generation; editing is done in the dedicated draft editor to make the publish boundary explicit. ### Node Detail (`/clusters/{ClusterId}/nodes/{NodeId}`) Per-node view for `ClusterNode` management. - **Physical attributes** form: Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, RedundancyRole - **ApplicationUri auto-suggest** behavior (per decision #86): - When creating a new node: prefilled with `urn:{Host}:OtOpcUa` - When editing an existing node: changing `Host` shows a warning banner — "ApplicationUri is not updated automatically. Changing it will require all OPC UA clients to re-establish trust." Operator must explicitly click an "Update ApplicationUri" button to apply the suggestion. - **Credentials** sub-tab: list of `ClusterNodeCredential` rows (kind, value, enabled, rotated-at). FleetAdmin can add/disable/rotate. Credential rotation flow is documented inline ("create new credential → wait for node to use it → disable old credential"). - **Per-node overrides** sub-tab: structured editor for `DriverConfigOverridesJson`. Surfaces the cluster's `DriverInstance` rows with their current `DriverConfig`, and lets the operator add path → value override entries per driver. Validation: override path must exist in the current draft's `DriverConfig`; loud failure if it doesn't (per the merge semantics in the schema doc). - **Generation state**: current applied generation, last-applied timestamp, last-applied status, last error if any - **Recent node activity**: filtered audit log ### Draft Editor (`/clusters/{ClusterId}/draft`) The primary edit surface. Three-panel layout: tree on the left (drivers → devices → tags), edit form on the right, validation panel at the bottom. - **Drivers panel**: add/edit/remove `DriverInstance` rows in the draft. Each driver type opens a driver-specific config screen (deferred per #27). Generic fields (Name, NamespaceUri, Enabled) are always editable. - **Devices panel**: scoped to the selected driver instance (where applicable) - **Tags panel**: - Tree view by `FolderPath` - Inline edit for individual tags (Name, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig JSON in a structured editor) - **Bulk operations**: select multiple tags → bulk edit (change poll group, access level, etc.) - **CSV import**: upload a CSV with `(DriverInstanceId, DeviceId?, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)` columns. Preview shows additions/modifications/removals against current draft, with row-level validation errors. Operator confirms or cancels. - **CSV export**: emit the same shape from the current published generation, useful as a starting point for bulk edits in Excel - **Validation panel** runs `sp_ValidateDraft` continuously (debounced) and surfaces FK errors, JSON schema errors, duplicate paths, missing references. Publish button is disabled while errors exist. - **Diff link** at top: opens the diff viewer comparing the draft against the current published generation ### Diff Viewer (`/clusters/{ClusterId}/draft/diff`) Three-column compare: previous published | draft | summary. Per-table sections (drivers, devices, tags, poll groups) with rows colored by change type: - Green: added in draft - Red: removed in draft - Yellow: modified (with field-level diff on hover/expand) Includes a **publish dialog** triggered from this view: required Notes field, optional "publish and apply now" vs. "publish and let nodes pick up on next poll" (the latter is the default; the former invokes a one-shot push notification, deferred per existing plan). ### Generation History (`/clusters/{ClusterId}/generations`) List of all generations for the cluster with: ID, status, published-by, published-at, notes, and a per-row "Roll back to this" action (FleetAdmin or ConfigEditor). Clicking a row opens the generation detail page (read-only view of all rows in that generation, with diff-against-current as a button). Rollback flow: 1. Operator clicks "Roll back to this generation" 2. Modal: "This will create a new published generation cloned from generation N. Both nodes of this cluster will pick up the change on their next poll. Notes (required):" 3. Confirm → invokes `sp_RollbackToGeneration` → immediate UI feedback that a new generation was published ### Credential Management (`/credentials`) FleetAdmin-only. Lists all `ClusterNodeCredential` rows fleet-wide, filterable by cluster/node/kind/enabled. Operations: add credential to node, disable credential, mark credential rotated. Rotation is the most common operation — the UI provides a guided flow ("create new → confirm node has used it once via `LastAppliedAt` advance → disable old"). ### Fleet Audit (`/audit`) Searchable / filterable view of `ConfigAuditLog` across all clusters. Filters: cluster, node, principal, event type, date range. Export to CSV for compliance. ## Real-Time Updates Blazor Server runs over SignalR by default. The Admin app uses two SignalR hubs: | Hub | Purpose | |-----|---------| | `FleetStatusHub` | Push `ClusterNodeGenerationState` changes (LastSeenAt updates, applied-generation transitions, status changes) to any open Fleet Overview or Cluster Detail page | | `AlertHub` | Push new sticky alerts (crash-loop circuit trips, failed applies) to all subscribed pages | Updates fan out from a backend `IHostedService` that polls `ClusterNodeGenerationState` every 5 s and diffs against last-known state. Pages subscribe selectively (Cluster Detail page subscribes to one cluster's updates; Fleet Overview subscribes to all). No polling from the browser. ## UX Rules - **Sticky alerts that don't auto-clear** — per the crash-loop circuit-breaker rule in `driver-stability.md`, alerts in the Active Alerts strip require explicit operator acknowledgment before clearing, regardless of whether the underlying state has recovered. "We crash-looped 3 times overnight" must remain visible the next morning. - **Publish boundary is explicit** — there is no "edit in place" path. All changes go through draft → diff → publish. The diff viewer is required reading before the publish dialog enables. - **Loud failures over silent fallbacks** — if validation fails, the publish button is disabled and the failures are listed; we never publish a generation with warnings hidden. If a node override path doesn't resolve in the draft, the override editor flags it red, not yellow. - **No auto-rewrite of `ApplicationUri`** — see Node Detail page above. The principle generalizes: any field that OPC UA clients pin trust to (`ApplicationUri`, certificate thumbprints) requires explicit operator action to change, never silent updates. - **Bulk operations always preview before commit** — CSV imports, bulk tag edits, rollbacks all show a diff and require confirmation. No "apply" buttons that act without preview. ## Per-Driver Config Screens (deferred) Per decision #27, driver-specific config screens are added in each driver's implementation phase, not up front. The Admin app provides: - A pluggable `IDriverConfigEditor` interface in `Configuration.Abstractions` - Driver projects implement an editor that renders into a slot on the Driver Detail screen - For drivers that don't yet have a custom editor, a generic JSON editor with schema-driven validation is used (better than nothing, ugly but functional) The generic JSON editor uses the per-driver JSON schema from `DriverTypeRegistry` so even pre-custom-editor, validation works. ## Workflows ### Add a new cluster 1. FleetAdmin: `/clusters` → "New cluster" 2. Form: Name, Site, NodeCount (1 or 2), RedundancyMode (auto-set based on NodeCount), NamespaceUri (auto-suggested from name) 3. Save → cluster row created (`Status = Enabled`, no generations yet) 4. Redirect to Cluster Detail; prompt to add nodes ### Add a node to a cluster 1. Cluster Detail → "Add node" 2. Form: NodeId, RedundancyRole, Host (required), OpcUaPort (default 4840), DashboardPort (default 8081), ApplicationUri (auto-prefilled `urn:{Host}:OtOpcUa`), ServiceLevelBase (auto: Primary=200, Secondary=150) 3. Save 4. Prompt: "Add a credential for this node now?" → opens credential add flow 5. The node won't be functional until at least one credential is added and the credential is provisioned on the node's machine (out-of-band step documented in deployment guide) ### Edit drivers/tags and publish 1. Cluster Detail → "Edit configuration" → opens draft editor (creates a draft generation if none exists) 2. Operator edits drivers, devices, tags, poll groups 3. Validation panel updates live; publish disabled while errors exist 4. Operator clicks "Diff" → diff viewer 5. Operator clicks "Publish" → modal asks for Notes, confirms 6. `sp_PublishGeneration` runs in transaction; on success, draft becomes new published generation; previous published becomes superseded 7. Within ~30 s (default poll interval), both nodes pick up the new generation; Cluster Detail page shows live progress as `LastAppliedAt` advances on each node ### Roll back 1. Cluster Detail → Generations tab → find target generation → "Roll back to this" 2. Modal: explains a new generation will be created (clone of target) and published; require Notes 3. Confirm → `sp_RollbackToGeneration` runs 4. Same propagation as a forward publish — both nodes pick up the new generation on next poll ### Override a setting per node 1. Node Detail → Overrides sub-tab 2. Pick driver instance from dropdown → schema-driven editor shows current `DriverConfig` keys 3. Add override row: select key path (validated against the driver's JSON schema), enter override value 4. Save → updates `ClusterNode.DriverConfigOverridesJson` 5. **No new generation created** — overrides are per-node metadata, not generation-versioned. They take effect on the node's next config-apply cycle. The "no new generation" choice is deliberate: overrides are operationally bound to a specific physical machine, not to the cluster's logical config evolution. A node replacement scenario would copy the override to the replacement node via the credential/override migration flow, not by replaying generation history. ### Rotate a credential 1. Node Detail → Credentials sub-tab → "Add credential" 2. Pick Kind, enter Value, save → new credential is enabled alongside the old 3. Wait for `LastAppliedAt` on the node to advance (proves the new credential is being used by the node — operator-side work to provision the new credential on the node's machine happens out-of-band) 4. Once verified, disable the old credential → only the new one is valid ## Deferred / Out of Scope - **Cluster-scoped admin grants** (`ConfigEditor` for Cluster X only, not for Cluster Y) — surface in v2.1 - **Per-driver custom config editors** — added in each driver's implementation phase - **Tag template / inheritance** — define a tag pattern once and apply to many similar device instances; deferred until the bulk import path proves insufficient - **Multi-cluster synchronized publish** — push a configuration change across many clusters atomically. Out of scope; orchestrate via per-cluster publishes from a script if needed. - **Mobile / tablet layout** — desktop-only initially - **Role grants editor in UI** — initial v2 manages LDAP group → admin role mappings via `appsettings.json`; UI editor surfaced later ## Decisions / Open Questions **Decided** (captured in `plan.md` decision log): - Blazor Server tech stack (vs. SPA + API) - **Visual + auth parity with ScadaLink CentralUI** — Bootstrap 5, dark sidebar, server-rendered login form, cookie auth + JWT API endpoint, copied shared component set, reconnect overlay - LDAP for operator auth via `LdapAuthService` + `RoleMapper` + `JwtTokenService` mirrored from `ScadaLink.Security` - Three admin roles: FleetAdmin / ConfigEditor / ReadOnly, with cluster-scoped grants in v2.0 (mirrored from ScadaLink's site-scoped pattern) - Draft → diff → publish is the only edit path; no in-place edits - Sticky alerts require manual ack - Per-node overrides are NOT generation-versioned **Resolved Defaults**: - **Styling: Bootstrap 5 vendored** (not MudBlazor or Fluent UI). Direct parity with ScadaLink CentralUI; standard component vocabulary; no Blazor-specific component-library dependency. Reverses an earlier draft choice — the cross-app consistency requirement outweighs MudBlazor's component conveniences. - **Theme: light only (single theme matching ScadaLink).** ScadaLink ships light-only with the dark sidebar / light main pattern. Operators using both apps see one consistent aesthetic. Reverses an earlier draft choice that proposed both light and dark — cross-app consistency wins. Revisit only if ScadaLink adds dark mode. - **CSV import dialect: strict CSV (RFC 4180), UTF-8 BOM accepted.** Excel "Save as CSV (UTF-8)" produces RFC 4180-compatible output and is the documented primary input format. TSV not supported initially; add only if operator feedback shows real friction with Excel CSV. - **Push notification deferred to v2.1; polling is initial model.** SignalR-from-DB-to-nodes would tighten apply latency from ~30 s to ~1 s but adds infrastructure (SignalR backplane or SQL Service Broker) that's not earning its keep at v2.0 scale. The publish dialog reserves a disabled **"Push now"** button labeled "Available in v2.1" so the future UX is anchored. - **Auto-save drafts with explicit Discard button.** Every form field change writes to the draft rows immediately (debounced 500 ms). The Discard button shows a confirmation dialog ("Discard all changes since last publish?") and rolls the draft generation back to empty. The Publish button is the only commit; auto-save does not publish. - **Cluster-scoped admin grants in v2.0** (lifted from v2.1 deferred list). ScadaLink already ships the equivalent site-scoped pattern, so we get cluster-scoped grants essentially for free by mirroring it. `RoleMapper` reads an `LdapGroupRoleMapping` table; cluster-scoped users carry `ClusterId` claims and see only their permitted clusters.