diff --git a/CLAUDE.md b/CLAUDE.md
index fe6087a6..b17bf8ee 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -678,7 +678,22 @@ Two things to know before relying on any of this:
an unregistered service, so both must be registered explicitly on every node (deny-all / empty on a
node with neither role) or it does not boot.
-Remaining stages, and the rationale for every decision above, are in
+**Grants are authored through a scope picker, not free text (stage 7).** `/clusters/{id}/acls` opens
+`AclScopePickerModal` over the cluster's raw + UNS trees, and selecting a node supplies `ScopeKind`
+**and** `ScopeId` together — so `RawDevice` + a TagId, a cross-cluster scope, a `Cluster` grant carrying
+an id, and the retired `FolderSegment` kind are all unauthorable by construction rather than merely
+rejected later. ⚠️ **A `Cluster` row must produce a NULL `ScopeId`** — the deploy gate rejects one that
+carries an id (`NodeAclScopeIdUnexpected`), so a picker that helpfully filled in the cluster id would
+author rows that cannot be deployed. `IAclScopeService.CheckScopeAsync` mirrors
+`DraftValidator.ValidateNodeAclScopes` rule for rule and **blocks the save** when a scope resolves to
+nothing (re-checked on submit, since a row loaded unchanged is exactly where the operator never touches
+the picker); the list page marks such rows `unresolved`. **UNS tags are deliberately not offered** — a
+UNS signal is a `UnsTagReference`, and a `Tag` grant already covers the raw node *and* every UNS
+projection of it. The picker is `AclScopeTree`, a separate component from `RawTree`, which already
+carries two modes.
+
+**All eight stages are done** (`d4389f57` enforcement, stage 7 authoring after it), each live-gated on
+docker-dev. The rationale for every decision above is in
`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. See `docs/security.md` for the full guide.
Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`, plaintext/`Transport=None`). It is managed via `scadaproj/infra/glauth/` (source of truth + deploy runbook). Single bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`. The docker-dev compose binds this shared instance directly — `DevStubMode` is no longer used. The per-VM NSSM GLAuth at `C:\publish\glauth\` and the old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. (The integration-test harness under `tests/.../Host.IntegrationTests/` runs its own ephemeral **GLAuth** container on port 3894 — seeded by `tests/.../Host.IntegrationTests/glauth/config.toml` with dev users alice/bob + the `cn=serviceaccount` bind account — for the opt-in `OTOPCUA_HARNESS_USE_LDAP=1` real-bind mode; distinct from the shared dev GLAuth on 3893. It replaced the retired `bitnami/openldap:2.6` in PR #451, 2026-07-15.)
diff --git a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md
index 20a9742d..ccbeae80 100644
--- a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md
+++ b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md
@@ -108,7 +108,10 @@ mismatch).
`Descend` like any other kind, so given a path it *would* have matched and been reported under
whatever level it happened to land on. It was unlabellable, not unplaceable.
- `NodePermissions.AlarmRead` has no `OpcUaOperation` mapping — grantable but unreachable.
-- `NodeAcl.ScopeId` has no FK and no existence check, so scope ids dangle silently.
+- ~~`NodeAcl.ScopeId` has no FK and no existence check, so scope ids dangle silently.~~ **Closed** —
+ stage 3 gave it a deploy-time existence check, and stage 7 an authoring-time one plus a picker that
+ cannot produce a dangling id. There is still no FK: `ScopeId` names a row in one of eight tables
+ chosen by `ScopeKind`, which is not a foreign key any schema can express.
- ~~`ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference `Core` at all~~ — **stale**; stage 1 added
the reference. The line read as an open item long after it was closed.
- `NodePermissions.HistoryUpdate` is deliberately in no bundle until HistoryUpdate exists. Leave it.
@@ -169,8 +172,8 @@ Each stage is one commit, builds clean, and leaves the tree shippable.
Also closed here: `NodeAcl` rows had **no deploy-time validation of any kind** and were not even
reachable from `DraftValidator` (`DraftSnapshot` carried no `NodeAcls` collection). They are now
gated for dangling ids, cross-cluster scopes, blank/contradictory `ScopeId`, the retired
- `FolderSegment` kind, and undefined ordinals. That gate is what substitutes for the ACL scope picker
- until stage 7 — `ScopeId` is still a free-text box.
+ `FolderSegment` kind, and undefined ordinals. That gate substituted for the ACL scope picker until
+ stage 7 shipped one; it remains the backstop for rows edited outside the AdminUI.
4. **Artifact + lifecycle.** ✅ **Done.** `ConfigComposer` snapshots `ApiKeys` alongside the `NodeAcls`
it already carried; `DeploymentArtifact.ParseDataPlaneAuthority` decodes both node-side (grants
scoped to the node's own cluster, keys not — a key is a fleet-wide credential and the ACL rows are
@@ -271,8 +274,37 @@ Each stage is one commit, builds clean, and leaves the tree shippable.
the moment anything injects a test clock — at which point the evaluator denies *everything* with a
verdict indistinguishable from "no grant matched". Stage 5's claim that these predicates "can never
fire" was accidentally true, not true by construction; they are now disabled explicitly.
-7. **AdminUI.** Key management (create/revoke, show-once) ✅ **done early, in stage 4's predecessor**; ACL authoring against principals, and
- **remove the two "not enforced" warning banners** — which is the honest signal that this is done.
+7. **AdminUI.** ✅ **Done.** Key management (create/revoke, show-once) landed early, in stage 4's
+ predecessor. This stage added the **scope picker** (`AclScopePickerModal` over the cluster's raw and
+ UNS trees, backed by `IAclScopeService`), authoring-time **scope and principal resolution**, and
+ reshaped the two banners.
+
+ Four things went differently from this line, all deliberate:
+
+ - **The banners were reshaped, not removed.** "Remove the two warning banners" assumed the only thing
+ they said was *not enforced*, which stage 6 falsified. What remains true is that enforcement is a
+ **per-node** flag the AdminUI cannot see, so an `Equipment`-scoped grant still confers nothing on a
+ default-configured node — deleting the banner would have replaced one wrong impression with
+ another. They are now `alert-info` describing both modes, flag-on first.
+ - **The picker sets the kind AND the id, and there is no longer a hand-edit path.** The plan asked
+ for "a `RawTree` picker for `ScopeId`", which would have left the kind dropdown beside it and kept
+ `RawDevice` + a TagId authorable. Selecting a tree node supplies both, which makes
+ `NodeAclScopeCrossCluster`, `NodeAclScopeIdUnexpected`, `NodeAclScopeIdMissing` and
+ `NodeAclScopeKindRetired` unauthorable by construction rather than merely discouraged.
+ - **It is a new component, not a third mode on `RawTree`.** `RawTree` already carries the `/raw`
+ editing tree and the equipment reference picker. A third mode would put the scope picker's bugs on
+ the device-authoring page, and with no bUnit in this repo the only thing that would catch that is
+ someone noticing. `AclScopeTree` renders a unified `AclScopeNode` instead, which is also what lets
+ ONE component serve both hierarchies — `RawTree` covers only the raw side.
+ - **Saving is blocked on an unresolved scope, and the list page marks the rot.** Not asked for; it is
+ what closes `ScopeId` dangling silently (the open item at the top of this doc) rather than moving
+ the discovery from deploy time to slightly-earlier deploy time. The check re-runs on submit rather
+ than trusting the verdict from when the form loaded, because a row loaded unchanged is exactly the
+ case where the operator never touches the picker.
+
+ Also fixed en route: the ACL editor had a `DataAnnotationsValidator` and **no `ValidationSummary`**,
+ so an invalid `NodeAclId` made the Create button a silent no-op — the [[project_adminui_no_bunit_live_verify]]
+ failure mode, present in this page since it was written.
8. **Rollout.** Docs, the ScadaBridge key hand-off, and flipping `Security:Acl:Enabled` on docker-dev
as the live gate.
@@ -350,6 +382,41 @@ Rig left as found: key, ACL row, the second device and its tag all deleted and r
returned to `Security:Acl:Enabled` unset, confirmed by the absence of the startup banner and an
anonymous read succeeding again.
+## Stage 7 live gate — PASSED (docker-dev, 2026-08-01)
+
+Driven through the AdminUI at `:9200` against cluster **SITE-A**. Both central containers were asserted
+to be running the same freshly-built image *by container id*, not from one `docker compose build` — the
+stale-central-2 trap from the stage-6 gate.
+
+| # | Leg | Result |
+|---|---|---|
+| 1 | Picker lazily expands the real raw tree | ✅ cluster → `sa-modbus2` → `plc` → `SiteAOnly`, one level per expand |
+| 2 | Each level offers its own scope kind | ✅ `CLUSTER` / `RAWDRIVER` / `RAWDEVICE` / `TAG` chips |
+| 3 | Selecting a node sets kind **and** id | ✅ → `RAWDEVICE` + `SITEA-dev`, "✓ Device 'plc' in cluster 'SITE-A'" |
+| 4 | Principal reaching nobody warns | ✅ `not-a-real-principal` → ⚠ "No active API key uses this principal" |
+| 5 | …and the positive control | ✅ `anonymous` → ✓ "The reserved anonymous principal" |
+| 6 | Grant saves and lists with its resolved target | ✅ |
+| 7 | A rotted row is marked on the list | ✅ id broken by SQL → `UNRESOLVED` chip + reason |
+| 8 | Saving a rotted row is **blocked** | ✅ "Scope: No device in this configuration has the id …" |
+| 9 | Re-picking a valid scope unblocks it | ✅ `NAMESPACE raw` → saved |
+| 10 | The authored grant **deploys** | ✅ `POST /api/deployments` → `Sealed` |
+| 11 | The deploy gate rejects what the page rejects | ✅ same row broken by SQL → HTTP 422 `NodeAclScopeUnresolved` |
+
+Legs 8 and 11 are the pair that matters: the page and the deploy gate reject the same row for the same
+reason, which is the claim "`CheckScopeAsync` mirrors `DraftValidator`" reduces to. Leg 11 also
+confirmed the *kind* is honoured on both sides — the row was re-pointed at `RawDriver`, and the gate's
+message named `DriverInstance`.
+
+Legs 5 and 9 are the positive controls. Without them the gate passes against a page that refuses
+everything, which is the same failure shape stage 6's `resolvedScopes=N` leg exists to catch.
+
+**Not covered:** a UNS-realm scope (SITE-A has no UNS areas, so the picker's UNS column correctly showed
+"no UNS areas yet" and only the namespace row was selectable there). `UnsArea` / `UnsLine` / `Equipment`
+selection is covered offline only.
+
+Rig restored afterwards: the gate's ACL row deleted and a further deployment sealed, leaving SITE-A at
+zero ACL rows as found.
+
## Known consequence to design around
**Driver-only nodes have no ConfigDb (Phase 4).** Keys and ACLs must therefore reach them inside the
diff --git a/docs/security.md b/docs/security.md
index d5920caa..c173ace7 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -228,12 +228,13 @@ Two consequences worth knowing:
no roles. Admitting is the pre-#520 behaviour, and defaulting to it precisely when the server cannot
determine what anonymous may do stops being harmless once reads are gated.
-⚠️ **Node ACLs are enforced only when `Security:Acl:Enabled` is set, and only for three operations.**
-With the flag **off — the default** — a key's grants are collapsed into the two server-wide role gates
-and **only `Cluster`-scoped grants count**, so a narrower grant confers nothing rather than silently
-widening to server-wide authority. With it **on**, writes, alarm commands and HistoryRead are decided
-by scoped `NodeAcl` grants; **reads, browses and subscriptions stay ungated in either mode** (stage 6).
-See [Node ACL enforcement](#node-acl-enforcement-securityaclenabled) and
+⚠️ **Node ACLs are enforced only when `Security:Acl:Enabled` is set.** With the flag **off — the
+default** — a key's grants are collapsed into the two server-wide role gates and **only
+`Cluster`-scoped grants count**, so a narrower grant confers nothing rather than silently widening to
+server-wide authority, and read, browse, subscribe and HistoryRead are ungated for any admitted
+session. With it **on**, **every** data-plane operation is decided by scoped `NodeAcl` grants — read,
+browse, subscribe, write, HistoryRead and the alarm commands (stage 6). See
+[Node ACL enforcement](#node-acl-enforcement-securityaclenabled) and
`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`.
### LDAP resilience — mostly retired with the data-plane authenticator (#520)
@@ -451,9 +452,10 @@ What is real about it:
necessary at stage 6's read/browse/subscribe seams, which are on the publish path — and it will want
a per-`(session, node)` decision cache, not this type.
-`NodeAclScopeKind.FolderSegment` is the retired v2 kind with no v3 entity behind it. It is still offered
-in the authoring dropdown (labelled retired), but it can never resolve, and both ends now say so
-specifically rather than reporting it as a missing id: the deploy gate rejects it with
+`NodeAclScopeKind.FolderSegment` is the retired v2 kind with no v3 entity behind it. The stage-7 picker
+does not offer it at all, so it can only arrive on a legacy row or a hand-edited one; it can never
+resolve, and every end now says so specifically rather than reporting it as a missing id: the AdminUI
+refuses to save it, the deploy gate rejects it with
`NodeAclScopeKindRetired`, and the trie builder drops it with a `RetiredScopeKind` diagnostic (Gitea
#533 — it used to arrive as `UnresolvedScope`, which sent an operator holding a legacy row hunting for a
typo in an id that was never the problem). One inconsistency remains: `NodePermissions.AlarmRead` has no
@@ -462,13 +464,56 @@ typo in an id that was never the problem). One inconsistency remains: `NodePermi
**String identity in this subsystem** (Gitea #533): **identifiers** — `ScopeId`, `ClusterId`,
`Principal` — compare `OrdinalIgnoreCase`, because they are opaque handles whose spelling genuinely
varies by source (a `ClusterId` arrives lower-cased through `OTOPCUA_ROLES` and verbatim through
-`Cluster:Roles`; a `ScopeId` is operator free-text until the stage-7 picker lands). **Path segments** —
+`Cluster:Roles`; a `ScopeId` comes from the stage-7 picker but a legacy row's may be anything).
+**Path segments** —
every level of a scope path, and therefore `PermissionTrieNode.Children` — compare `Ordinal`, because
they are browse names and `Speed` / `speed` are two different nodes that can coexist under one device.
The rule is stated in full on `AclScopeRef`. It matters most where the deploy gate and the node are fed
from *different* snapshots, which is exactly the split `AclScopePathResolver` exists to bridge: case
drift there would silently drop every grant on the affected scope.
+### Authoring grants (#520 stage 7)
+
+Grants are authored at **`/clusters/{clusterId}/acls`**. Two things about the surface are worth knowing
+before relying on it, because both close a way a grant could previously be saved and deployed while
+authorizing nobody.
+
+**The scope kind and the scope target are chosen together, from a tree.** "Choose scope…" opens a
+picker showing the cluster's **raw device tree** (lazily expanded, exactly the `/raw` hierarchy) beside
+its **UNS tree**, plus a whole-namespace row on each side. Selecting a node sets `ScopeKind` *and*
+`ScopeId` in one action. Until this landed the two were an unrelated dropdown and a free-text box, so
+`RawDevice` + a TagId was a well-formed row: it saved, it deployed, and it resolved to nothing.
+
+Consequences of the trees being rooted at the grant's own cluster and of a node supplying its own kind:
+
+- A **cross-cluster** scope is structurally unreachable — the picker's counterpart to the deploy gate's
+ `NodeAclScopeCrossCluster` rule, rather than a second copy of it.
+- A **Cluster** row deliberately produces **no** `ScopeId`. The deploy gate rejects a Cluster grant that
+ carries one (`NodeAclScopeIdUnexpected`), so a picker that helpfully filled in the cluster id would
+ author rows that cannot be deployed.
+- **UNS tags are not offered.** A UNS signal is a `UnsTagReference` to a raw tag, and a `Tag` grant
+ already covers the raw node *and* every UNS projection of it — so a UNS tag row would produce a grant
+ that reads narrower than the one it creates. Pick the tag in the raw tree.
+- `FolderSegment` is not offered at all (see above).
+
+**Rows are resolved against the live configuration, at authoring time.** `IAclScopeService.CheckScopeAsync`
+mirrors `DraftValidator.ValidateNodeAclScopes` rule for rule, so the page rejects what the deployment
+would reject:
+
+- The editor **refuses to save** a grant whose scope does not resolve — re-checked on submit, not
+ trusted from when the form loaded, because an existing row's entity may have been deleted since and
+ the operator may never touch the picker.
+- The **list page** resolves every row and marks a broken one `unresolved` with the reason, so a grant
+ that has rotted since it was authored is visible without waiting for a deploy to fail.
+- The **principal** field suggests every known principal (API keys, existing grants, `anonymous`) and
+ reports how many *live* keys currently authenticate as the one typed. Reaching nobody is a **warning,
+ not a block** — authoring a grant before minting its key is legitimate; a typo is not, and the two are
+ indistinguishable to the server.
+
+The checks are in `AclScopeService` rather than in the Razor pages because this repo has no bUnit:
+logic only a page can reach is untestable, and "the grant authorizes nobody" is not a failure mode to
+leave unpinned.
+
### Hierarchy (design)
ACLs are evaluated against the node's scope path. `NodeScope` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs`) carries a `Kind` that selects between two hierarchy shapes:
@@ -567,9 +612,10 @@ to those role strings via `GroupToRole`, e.g.:
⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and
`AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and
`WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group
-to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (reads are ungated for
-everyone regardless). Earlier revisions of this document listed all five as "code-true"; that was
-wrong for three of them.
+to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (with the flag off,
+reads are ungated for everyone regardless; with it on, these role strings are not consulted at all —
+the scoped ACL decision replaces them). Earlier revisions of this document listed all five as
+"code-true"; that was wrong for three of them.
If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return
`BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeNode.cs
new file mode 100644
index 00000000..a4d3a357
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeNode.cs
@@ -0,0 +1,111 @@
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
+
+///
+/// One row in the ACL scope picker (#520 stage 7) — a place a NodeAcl grant can be scoped at,
+/// in either the raw device tree or the UNS tree.
+///
+///
+///
+/// Why a third tree view-model rather than reusing . The picker's job is
+/// to produce a and a ScopeIdtogether, from both
+/// hierarchies, in one list. covers only the raw side and
+/// only the UNS side, and neither carries a scope kind — so a shared shape is what lets one renderer
+/// and one selection model serve both. The raw side keeps its lazy loading by carrying its source
+/// node in .
+///
+///
+/// A node with a null is a label only (the enterprise/cluster heading rows,
+/// and UNS tag rows — see for why those are deliberately not
+/// selectable).
+///
+///
+public sealed class AclScopeNode
+{
+ /// Gets the stable per-node key, unique within the picker tree.
+ public required string Key { get; init; }
+
+ /// Gets the label shown in the tree.
+ public required string DisplayName { get; init; }
+
+ /// Gets the leading glyph, matching the icon vocabulary of the /raw and /uns trees.
+ public required string Icon { get; init; }
+
+ ///
+ /// Gets the scope kind this row grants at, or when the row is a label that
+ /// cannot be selected.
+ ///
+ public NodeAclScopeKind? ScopeKind { get; init; }
+
+ ///
+ /// Gets the NodeAcl.ScopeId this row produces. Null for a
+ /// row, which covers its whole cluster and carries no id, and for a label row.
+ ///
+ public string? ScopeId { get; init; }
+
+ /// Gets the ancestry label shown once selected, e.g. Plant / Modbus / dev1.
+ public required string PathLabel { get; init; }
+
+ /// Gets a value indicating whether this node's children load on first expand.
+ public bool HasLazyChildren { get; init; }
+
+ /// Gets the eagerly-materialised children (the whole UNS side; the raw side's roots only).
+ public List Children { get; } = new();
+
+ ///
+ /// Gets the raw-tree node this row projects, when there is one. The picker hands it back to
+ /// to expand the next level; null on the UNS side,
+ /// which is loaded whole.
+ ///
+ public RawNode? RawSource { get; init; }
+
+ // --- Transient UI state (never persisted) ---
+
+ /// Gets or sets a value indicating whether the node is expanded.
+ public bool Expanded { get; set; }
+
+ /// Gets or sets a value indicating whether the lazy children have been loaded.
+ public bool Loaded { get; set; }
+
+ /// Gets or sets a value indicating whether a lazy load is in flight.
+ public bool Loading { get; set; }
+
+ /// Gets or sets the last load error, if any.
+ public string? Error { get; set; }
+
+ /// Gets a value indicating whether this row can be chosen as a grant scope.
+ public bool Selectable => ScopeKind is not null;
+}
+
+/// The two scope trees for one cluster, as the picker shows them.
+/// The raw device tree, rooted at the cluster; empty when the cluster is unknown.
+/// The UNS tree (area → line → equipment) for the same cluster.
+public readonly record struct AclScopeRoots(
+ IReadOnlyList Raw,
+ IReadOnlyList Uns);
+
+///
+/// The outcome of resolving one authored (ScopeKind, ScopeId) pair against the current
+/// configuration — the authoring-time mirror of the deploy gate's NodeAclScope* rules.
+///
+/// Whether the scope names something the grant can actually be evaluated against.
+///
+/// What it resolves to when , or what is wrong and how to fix it when not.
+///
+public readonly record struct AclScopeCheck(bool Ok, string Message);
+
+/// How many API keys a principal name currently reaches.
+/// Whether this is the reserved anonymous principal, which has no key.
+/// Enabled, unexpired keys authenticating as this principal.
+/// Keys naming this principal that are disabled or already expired.
+public readonly record struct PrincipalUsage(bool IsAnonymous, int EnabledKeys, int DisabledKeys)
+{
+ ///
+ /// Gets a value indicating whether anything can currently present this principal. False means the
+ /// grant is authored against nobody — which is legitimate while a key is still to be minted, and a
+ /// typo otherwise, so it is surfaced rather than blocked.
+ ///
+ public bool ReachesSomething => IsAnonymous || EnabledKeys > 0;
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeService.cs
new file mode 100644
index 00000000..8a9ea8b2
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeService.cs
@@ -0,0 +1,363 @@
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
+
+/// EF-backed .
+///
+/// The two trees are projected from the services that already own them —
+/// for the raw side (keeping its lazy expansion) and
+/// for the UNS side (which loads whole) — so the picker cannot show a
+/// hierarchy that disagrees with /raw or /uns. Only and the
+/// principal lookups go to the database directly.
+///
+public sealed class AclScopeService : IAclScopeService
+{
+ private readonly IDbContextFactory _dbFactory;
+ private readonly IRawTreeService _raw;
+ private readonly IUnsTreeService _uns;
+ private readonly TimeProvider _time;
+
+ /// Initializes a new instance of the class.
+ /// The config DB context factory.
+ /// The raw project-tree service.
+ /// The UNS tree service.
+ /// Clock used to decide whether a key has already expired.
+ public AclScopeService(
+ IDbContextFactory dbFactory,
+ IRawTreeService raw,
+ IUnsTreeService uns,
+ TimeProvider? time = null)
+ {
+ _dbFactory = dbFactory;
+ _raw = raw;
+ _uns = uns;
+ _time = time ?? TimeProvider.System;
+ }
+
+ ///
+ public async Task LoadRootsAsync(string clusterId, CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
+
+ // Both services root their trees at Enterprise → Cluster, but searching the roots as well as their
+ // children keeps this working if a cluster ever surfaces at the top level.
+ var rawRoots = await _raw.LoadRootsAsync(ct).ConfigureAwait(false);
+ var rawCluster = rawRoots
+ .Concat(rawRoots.SelectMany(r => r.Children))
+ .FirstOrDefault(n => n.Kind == RawNodeKind.Cluster
+ && string.Equals(n.EntityId, clusterId, StringComparison.Ordinal));
+
+ var unsRoots = await _uns.LoadStructureAsync(ct).ConfigureAwait(false);
+ var unsCluster = unsRoots
+ .Concat(unsRoots.SelectMany(r => r.Children))
+ .FirstOrDefault(n => n.Kind == UnsNodeKind.Cluster
+ && string.Equals(n.EntityId, clusterId, StringComparison.Ordinal));
+
+ var raw = rawCluster is null
+ ? Array.Empty()
+ : new[] { ProjectRaw(rawCluster, parentPath: null) };
+
+ var uns = unsCluster is null
+ ? Array.Empty()
+ : ProjectUnsChildren(unsCluster, parentPath: null);
+
+ return new AclScopeRoots(raw, uns);
+ }
+
+ ///
+ public async Task> LoadChildrenAsync(
+ AclScopeNode parent, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(parent);
+ if (parent.RawSource is null) return Array.Empty();
+
+ var children = await _raw.LoadChildrenAsync(parent.RawSource, ct).ConfigureAwait(false);
+ return children.Select(c => ProjectRaw(c, parent.PathLabel)).ToList();
+ }
+
+ ///
+ public async Task CheckScopeAsync(
+ string clusterId, NodeAclScopeKind kind, string? scopeId, CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
+
+ // Mirrors DraftValidator.ValidateNodeAclScopes, in its order, so the page and the deploy gate
+ // cannot disagree about which rows are authorable. The messages are shorter because the operator
+ // is looking at the field; the rules are the same.
+ if (!Enum.IsDefined(kind))
+ return new AclScopeCheck(false,
+ $"Scope kind ordinal {(int)kind} is not a defined kind — re-pick the scope.");
+
+ if (kind == NodeAclScopeKind.Cluster)
+ return string.IsNullOrWhiteSpace(scopeId)
+ ? new AclScopeCheck(true, $"The whole of cluster '{clusterId}', in both namespaces.")
+ : new AclScopeCheck(false,
+ "A Cluster grant covers the whole cluster and ignores the scope target — clear the " +
+ "target, or pick the level the id names. Leaving both would grant more than intended.");
+
+ if (kind == NodeAclScopeKind.FolderSegment)
+ return new AclScopeCheck(false,
+ "FolderSegment is a retired v2 kind with no v3 entity behind it; it can never resolve, " +
+ "whatever its target. Re-scope to RawFolder.");
+
+ if (string.IsNullOrWhiteSpace(scopeId))
+ return new AclScopeCheck(false,
+ $"{kind} needs a scope target. Only a Cluster grant may omit one; without it the grant " +
+ "resolves to no node and is dropped when the configuration is built.");
+
+ var id = scopeId.Trim();
+
+ if (kind == NodeAclScopeKind.Namespace)
+ {
+ var realm = NodeAclRealms.TryCanonicalize(id);
+ return realm is null
+ ? new AclScopeCheck(false,
+ $"'{id}' names no OPC UA namespace. Use '{NodeAclRealms.Raw}' or '{NodeAclRealms.Uns}'.")
+ : new AclScopeCheck(true,
+ realm == NodeAclRealms.Raw
+ ? $"Every raw node in cluster '{clusterId}'."
+ : $"Every UNS node in cluster '{clusterId}'.");
+ }
+
+ await using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
+
+ // Each arm names the entity type the kind's ScopeId is drawn from, and walks to the cluster that
+ // owns it. Switch arms evaluate lazily, so only the matched kind's query runs. No AsNoTracking:
+ // every arm projects into Found, and EF tracks nothing it does not materialise as an entity.
+ var (entity, owner, name) = kind switch
+ {
+ NodeAclScopeKind.RawFolder => await ResolveAsync(
+ "raw folder",
+ db.RawFolders.Where(f => f.RawFolderId == id).Select(f => new Found(f.ClusterId, f.Name)),
+ ct).ConfigureAwait(false),
+
+ NodeAclScopeKind.RawDriver => await ResolveAsync(
+ "driver",
+ db.DriverInstances.Where(d => d.DriverInstanceId == id).Select(d => new Found(d.ClusterId, d.Name)),
+ ct).ConfigureAwait(false),
+
+ NodeAclScopeKind.RawDevice => await ResolveAsync(
+ "device",
+ from d in db.Devices
+ where d.DeviceId == id
+ join dr in db.DriverInstances on d.DriverInstanceId equals dr.DriverInstanceId
+ select new Found(dr.ClusterId, d.Name),
+ ct).ConfigureAwait(false),
+
+ NodeAclScopeKind.RawTagGroup => await ResolveAsync(
+ "tag group",
+ from g in db.TagGroups
+ where g.TagGroupId == id
+ join dev in db.Devices on g.DeviceId equals dev.DeviceId
+ join dr in db.DriverInstances on dev.DriverInstanceId equals dr.DriverInstanceId
+ select new Found(dr.ClusterId, g.Name),
+ ct).ConfigureAwait(false),
+
+ NodeAclScopeKind.Tag => await ResolveAsync(
+ "raw tag",
+ from t in db.Tags
+ where t.TagId == id
+ join dev in db.Devices on t.DeviceId equals dev.DeviceId
+ join dr in db.DriverInstances on dev.DriverInstanceId equals dr.DriverInstanceId
+ select new Found(dr.ClusterId, t.Name),
+ ct).ConfigureAwait(false),
+
+ NodeAclScopeKind.UnsArea => await ResolveAsync(
+ "UNS area",
+ db.UnsAreas.Where(a => a.UnsAreaId == id).Select(a => new Found(a.ClusterId, a.Name)),
+ ct).ConfigureAwait(false),
+
+ NodeAclScopeKind.UnsLine => await ResolveAsync(
+ "UNS line",
+ from l in db.UnsLines
+ where l.UnsLineId == id
+ join a in db.UnsAreas on l.UnsAreaId equals a.UnsAreaId
+ select new Found(a.ClusterId, l.Name),
+ ct).ConfigureAwait(false),
+
+ NodeAclScopeKind.Equipment => await ResolveAsync(
+ "equipment",
+ from e in db.Equipment
+ where e.EquipmentId == id
+ join l in db.UnsLines on e.UnsLineId equals l.UnsLineId
+ join a in db.UnsAreas on l.UnsAreaId equals a.UnsAreaId
+ select new Found(a.ClusterId, e.Name),
+ ct).ConfigureAwait(false),
+
+ // Cluster / Namespace / FolderSegment all returned above, and an undefined ordinal did too.
+ _ => ("scope", null, null),
+ };
+
+ if (owner is null)
+ return new AclScopeCheck(false,
+ $"No {entity} in this configuration has the id '{id}' — or its parent chain is broken, so " +
+ "no cluster owns it. The grant would be dropped when the configuration is built and would " +
+ "authorize nothing.");
+
+ if (!string.Equals(owner, clusterId, StringComparison.OrdinalIgnoreCase))
+ return new AclScopeCheck(false,
+ $"That {entity} belongs to cluster '{owner}', but this grant is in cluster '{clusterId}'. " +
+ "The id is real, so the grant would confer authority across a cluster boundary. Author it " +
+ $"under '{owner}' instead.");
+
+ return new AclScopeCheck(true,
+ $"{char.ToUpperInvariant(entity[0])}{entity[1..]} '{name}' in cluster '{clusterId}'.");
+ }
+
+ ///
+ public async Task> LoadKnownPrincipalsAsync(CancellationToken ct = default)
+ {
+ await using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
+
+ var fromKeys = await db.ApiKeys.AsNoTracking().Select(k => k.Principal).Distinct()
+ .ToListAsync(ct).ConfigureAwait(false);
+ var fromAcls = await db.NodeAcls.AsNoTracking().Select(a => a.Principal).Distinct()
+ .ToListAsync(ct).ConfigureAwait(false);
+
+ return fromKeys
+ .Concat(fromAcls)
+ .Append(OpcUaPrincipals.Anonymous)
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Select(p => p.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ ///
+ public async Task CheckPrincipalAsync(string principal, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(principal)) return new PrincipalUsage(false, 0, 0);
+
+ var name = principal.Trim();
+ if (string.Equals(name, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase))
+ return new PrincipalUsage(true, 0, 0);
+
+ await using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
+
+ var keys = await db.ApiKeys.AsNoTracking()
+ .Where(k => k.Principal == name)
+ .Select(k => new { k.Enabled, k.ExpiresUtc })
+ .ToListAsync(ct).ConfigureAwait(false);
+
+ var now = _time.GetUtcNow().UtcDateTime;
+ var live = keys.Count(k => k.Enabled && (k.ExpiresUtc is null || k.ExpiresUtc > now));
+ return new PrincipalUsage(false, live, keys.Count - live);
+ }
+
+ // --- Projection --------------------------------------------------------------------------------
+
+ private static AclScopeNode ProjectRaw(RawNode node, string? parentPath)
+ {
+ var kind = RawScopeKind(node.Kind);
+ var path = Join(parentPath, node.DisplayName);
+
+ var projected = new AclScopeNode
+ {
+ Key = node.Key,
+ DisplayName = node.DisplayName,
+ Icon = RawIcon(node.Kind),
+ ScopeKind = kind,
+ // A Cluster grant covers its whole cluster and carries no id — the deploy gate rejects one
+ // that does, so the picker must not produce one.
+ ScopeId = kind == NodeAclScopeKind.Cluster ? null : node.EntityId,
+ PathLabel = path,
+ HasLazyChildren = node.HasLazyChildren,
+ RawSource = node,
+ };
+
+ foreach (var child in node.Children) projected.Children.Add(ProjectRaw(child, path));
+ return projected;
+ }
+
+ private static IReadOnlyList ProjectUnsChildren(UnsNode cluster, string? parentPath) =>
+ cluster.Children.Select(c => ProjectUns(c, parentPath)).ToList();
+
+ private static AclScopeNode ProjectUns(UnsNode node, string? parentPath)
+ {
+ var path = Join(parentPath, node.DisplayName);
+
+ var projected = new AclScopeNode
+ {
+ Key = node.Key,
+ DisplayName = node.DisplayName,
+ Icon = UnsIcon(node.Kind),
+ ScopeKind = UnsScopeKind(node.Kind),
+ ScopeId = node.EntityId,
+ PathLabel = path,
+ HasLazyChildren = false,
+ // Everything below the cluster is already materialised — nothing to expand lazily, and no raw
+ // node to expand it from.
+ RawSource = null,
+ Expanded = true,
+ };
+
+ foreach (var child in node.Children) projected.Children.Add(ProjectUns(child, path));
+ return projected;
+ }
+
+ private static string Join(string? parent, string name) =>
+ string.IsNullOrEmpty(parent) ? name : $"{parent} / {name}";
+
+ private static NodeAclScopeKind? RawScopeKind(RawNodeKind kind) => kind switch
+ {
+ RawNodeKind.Cluster => NodeAclScopeKind.Cluster,
+ RawNodeKind.Folder => NodeAclScopeKind.RawFolder,
+ RawNodeKind.Driver => NodeAclScopeKind.RawDriver,
+ RawNodeKind.Device => NodeAclScopeKind.RawDevice,
+ RawNodeKind.TagGroup => NodeAclScopeKind.RawTagGroup,
+ RawNodeKind.Tag => NodeAclScopeKind.Tag,
+ _ => null, // Enterprise: a label, not a scope.
+ };
+
+ // Area/Line/Equipment only. A UNS tag row is a UnsTagReference, not a Tag — its scope is the RAW tag it
+ // points at, and a Tag grant already covers the raw node AND every UNS projection of it (see
+ // AclScopePathResolver). Offering a UNS tag here would produce a grant that reads as narrower than the
+ // one it actually creates.
+ private static NodeAclScopeKind? UnsScopeKind(UnsNodeKind kind) => kind switch
+ {
+ UnsNodeKind.Area => NodeAclScopeKind.UnsArea,
+ UnsNodeKind.Line => NodeAclScopeKind.UnsLine,
+ UnsNodeKind.Equipment => NodeAclScopeKind.Equipment,
+ _ => null,
+ };
+
+ private static string RawIcon(RawNodeKind kind) => kind switch
+ {
+ RawNodeKind.Enterprise => "🏢",
+ RawNodeKind.Cluster => "🖥️",
+ RawNodeKind.Folder => "📁",
+ RawNodeKind.Driver => "🔌",
+ RawNodeKind.Device => "📟",
+ RawNodeKind.TagGroup => "🗂️",
+ RawNodeKind.Tag => "🏷️",
+ _ => "•",
+ };
+
+ private static string UnsIcon(UnsNodeKind kind) => kind switch
+ {
+ UnsNodeKind.Area => "🗺️",
+ UnsNodeKind.Line => "🏭",
+ UnsNodeKind.Equipment => "⚙️",
+ _ => "•",
+ };
+
+ // --- Resolution helpers ------------------------------------------------------------------------
+
+ // A class, not a struct: FirstOrDefaultAsync over a struct yields default(T), which is indistinguishable
+ // from a real row whose fields happen to be empty — a null reference cannot be misread that way.
+ private sealed record Found(string ClusterId, string Name);
+
+ private static async Task<(string Entity, string? Owner, string? Name)> ResolveAsync(
+ string entity, IQueryable query, CancellationToken ct)
+ {
+ // FirstOrDefault, not Single: a duplicated id is a different rule's business, and throwing here
+ // would replace a precise message with an exception page.
+ var hit = await query.FirstOrDefaultAsync(ct).ConfigureAwait(false);
+ return hit is null ? (entity, null, null) : (entity, hit.ClusterId, hit.Name);
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/IAclScopeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/IAclScopeService.cs
new file mode 100644
index 00000000..4f622338
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/IAclScopeService.cs
@@ -0,0 +1,72 @@
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
+
+///
+/// Backs the ACL authoring surface (#520 stage 7): the scope picker's two trees, and the
+/// authoring-time checks that stop a grant being saved against a principal or a scope that names
+/// nothing.
+///
+///
+///
+/// A service rather than logic in the Razor pages, because this repo has no bUnit — anything
+/// only a page can reach is untestable, and the whole point of the stage is that authoring stops
+/// producing grants that silently authorize nobody. The same rationale
+/// IApiKeyAdminService carries.
+///
+///
+/// The picker chooses the kind and the id together. Before this, ScopeKind was a
+/// dropdown and ScopeId a free-text box, so the two could disagree — a
+/// row pointing at a TagId is well-formed, saves, deploys,
+/// and resolves to nothing. Selecting a tree node produces both, so that pairing cannot be made by
+/// hand. is what catches the rows that already exist, or that were
+/// typed in before the picker was reached.
+///
+///
+public interface IAclScopeService
+{
+ ///
+ /// Loads the raw and UNS scope trees for one cluster. Both are rooted at that cluster, so a scope in
+ /// another cluster is unreachable through the picker rather than merely discouraged — the structural
+ /// equivalent of the deploy gate's NodeAclScopeCrossCluster rule.
+ ///
+ /// The cluster the grant belongs to.
+ /// The cancellation token.
+ /// The two trees; either may be empty when the cluster has nothing of that kind yet.
+ Task LoadRootsAsync(string clusterId, CancellationToken ct = default);
+
+ ///
+ /// Loads one lazy level beneath a raw-tree node. UNS nodes are loaded whole by
+ /// and never reach here.
+ ///
+ /// The node being expanded.
+ /// The cancellation token.
+ /// The child rows, or empty for a leaf.
+ Task> LoadChildrenAsync(AclScopeNode parent, CancellationToken ct = default);
+
+ ///
+ /// Resolves an authored scope against the current configuration, mirroring the deploy gate's
+ /// NodeAclScope* rules so the page rejects what the deployment would reject.
+ ///
+ /// The cluster the grant belongs to.
+ /// The authored scope kind.
+ /// The authored scope id; null or blank for a cluster-wide grant.
+ /// The cancellation token.
+ /// What the scope resolves to, or why it does not.
+ Task CheckScopeAsync(
+ string clusterId, NodeAclScopeKind kind, string? scopeId, CancellationToken ct = default);
+
+ ///
+ /// Lists the principal names worth suggesting: every principal an API key authenticates as, every
+ /// principal an existing grant already names, and the reserved anonymous principal.
+ ///
+ /// The cancellation token.
+ /// Distinct principal names, ordered.
+ Task> LoadKnownPrincipalsAsync(CancellationToken ct = default);
+
+ /// Reports how many API keys currently authenticate as a principal.
+ /// The principal name as authored.
+ /// The cancellation token.
+ /// The usage counts.
+ Task CheckPrincipalAsync(string principal, CancellationToken ct = default);
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor
index 487fc605..47928dde 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor
@@ -5,10 +5,13 @@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
+@using ZB.MOM.WW.OtOpcUa.Core.Authorization
@inject IDbContextFactory DbFactory
+@inject IAclScopeService Scopes
@inject NavigationManager Nav
@@ -28,47 +31,35 @@ else if (!IsNew && _existing is null)
}
else
{
-
+
What this grant does depends on Security:Acl:Enabled on the
node serving the data. That is per-node configuration, so this page cannot tell you
which mode a given node is in — check the node's own settings.
- With it off (the default): saved with scope kind
- Cluster this grant confers a server-wide role on its
- principal — WriteOperate → writing any tag value;
- AlarmAcknowledge → the Part 9 alarm methods
- (acknowledge / confirm / shelve). Saved with any narrower scope
- (Namespace, UnsArea,
- UnsLine, Equipment,
- Tag, Raw*) it is stored and shipped in every
- deployment artifact but confers nothing. That under-granting is deliberate:
- honouring its flags while its scope cannot be honoured would turn "WriteOperate on one
- equipment" into server-wide write authority.
- With it on: the scope is honoured, and the scoped decision replaces the
server-wide role rather than adding to it. Every data-plane operation is gated —
Read, Browse,
Subscribe, WriteOperate,
HistoryRead and the alarm methods, which separate into
AlarmAcknowledge / AlarmConfirm /
- AlarmShelve. So a grant that omits
- Read denies reads, and one that omits
- Browse hides the node from navigation.
-
+ AlarmShelve. So a grant that omits Read
+ denies reads, and one that omits Browse hides the node from navigation.
Granting Browse on a node also makes the folders above it
browsable — otherwise the grant could never be navigated to — so it reveals their names.
Revoking this grant stops any live subscription it was authorizing, on the next deployment.
- Principal is an API-key principal — or the reserved
- anonymous principal — not an LDAP group: a client presents an OPC UA
- UserName token with username apikey and password
- otua_<keyId>_<secret>. Mint keys on the
- /apikeys page; LDAP now covers AdminUI login only.
- See docs/security.md.
+ With it off (the default): only a Cluster-scoped grant
+ does anything, and it confers a server-wide role on its principal —
+ WriteOperate → writing any tag value;
+ AlarmAcknowledge → the Part 9 alarm methods. Any narrower scope is
+ stored and shipped but confers nothing. That under-granting is deliberate:
+ honouring a grant's flags while its scope cannot be honoured would turn "WriteOperate on
+ one equipment" into server-wide write authority.
+
Grant
@@ -79,49 +70,71 @@ else
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
- Free text, no picker — a typo saves a grant that resolves to nothing.
- Cluster → leave blank ·
- Namespace → raw or
- uns ·
- UnsArea → a UnsAreaId ·
- UnsLine → a UnsLineId ·
- Equipment → an EquipmentId ·
- Tag → a TagId (a raw tag; the grant covers the raw
- node and every UNS projection of it) ·
- RawFolder → a RawFolderId ·
- RawDriver → a DriverInstanceId ·
- RawDevice → a DeviceId ·
- RawTagGroup → a TagGroupId ·
- FolderSegment → retired v2 kind, resolves nothing.
+ @if (string.IsNullOrWhiteSpace(_form.Principal))
+ {
+
+ An API key's principal, or the reserved
+ @OpcUaPrincipals.Anonymous principal that an
+ anonymous session authenticates as. Mint keys on
+ /apikeys.
+
+ }
+ else if (_principal.IsAnonymous)
+ {
+
+ ✓ The reserved anonymous principal — every session admitted without a
+ key authenticates as this.
+
+ }
+ else if (_principal.EnabledKeys > 0)
+ {
+
+ ✓ @_principal.EnabledKeys active API key@(_principal.EnabledKeys == 1 ? "" : "s")
+ @(_principal.DisabledKeys > 0 ? $" (+{_principal.DisabledKeys} revoked or expired)" : "")
+ authenticate@(_principal.EnabledKeys == 1 ? "s" : "") as this principal.
+
+ }
+ else
+ {
+
+ ⚠ No active API key uses this principal@(_principal.DisabledKeys > 0 ? " (only revoked or expired ones)" : "") —
+ the grant is valid but currently authorizes nobody. That is fine if the
+ key is still to be minted; otherwise check the spelling.
+
+ }
+ Permissions inherit downward: a grant on a device covers every tag beneath it.
+ Picking the scope from the tree is what keeps the kind and the target in step —
+ a kind that disagrees with its target resolves to nothing and authorizes nobody.
+
+
+
@@ -162,6 +175,9 @@ else
@if (!IsNew) { }
+
+
}
@code {
@@ -183,6 +199,11 @@ else
private bool _loaded;
private bool _busy;
private string? _error;
+ private bool _pickerVisible;
+
+ private IReadOnlyList _knownPrincipals = Array.Empty();
+ private PrincipalUsage _principal;
+ private AclScopeCheck _scope;
protected override async Task OnInitializedAsync()
{
@@ -205,14 +226,46 @@ else
};
}
}
+
+ _knownPrincipals = await Scopes.LoadKnownPrincipalsAsync();
+ await RefreshPrincipalAsync();
+ await RefreshScopeAsync();
_loaded = true;
}
+ private void OpenPicker()
+ {
+ _pickerVisible = true;
+ StateHasChanged();
+ }
+
+ private async Task ScopePicked(AclScopeNode node)
+ {
+ // The picker supplies both halves at once — the pairing is what stops a kind naming one entity
+ // type while its id names another.
+ _form.ScopeKind = node.ScopeKind!.Value;
+ _form.ScopeId = node.ScopeId;
+ _pickerVisible = false;
+ await RefreshScopeAsync();
+ }
+
+ private async Task RefreshPrincipalAsync() =>
+ _principal = await Scopes.CheckPrincipalAsync(_form.Principal ?? "");
+
+ private async Task RefreshScopeAsync() =>
+ _scope = await Scopes.CheckScopeAsync(ClusterId, _form.ScopeKind, _form.ScopeId);
+
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
+ // Re-check rather than trust the cached verdict: an existing row may have been loaded with a
+ // scope whose entity has since been deleted, and the operator may never have touched the
+ // picker. Blocking here is what stops the page saving a grant the deployment would reject.
+ await RefreshScopeAsync();
+ if (!_scope.Ok) { _error = $"Scope: {_scope.Message}"; return; }
+
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor
index f8bac332..365fb3ac 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor
@@ -2,9 +2,11 @@
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory DbFactory
+@inject IAclScopeService Scopes
ACLs · @ClusterId
@@ -19,24 +21,12 @@
}
else
{
-
+
What these grants do depends on
Security:Acl:Enabled on the node serving the data.
That is per-node configuration, so this page cannot tell you which mode a given node is in —
check the node's own settings.
- With it off (the default): only Cluster-scoped grants
- do anything, and they confer a server-wide role on their principal —
- WriteOperate → writing any tag value;
- AlarmAcknowledge → the Part 9 alarm methods
- (acknowledge / confirm / shelve). Narrower grants
- (Namespace, UnsArea,
- UnsLine, Equipment,
- Tag, Raw*) are saved and shipped in every
- deployment artifact but confer nothing. That under-granting is deliberate:
- honouring such a grant's flags while its scope cannot be honoured would turn
- "WriteOperate on one equipment" into server-wide write authority.
- With it on: every scope kind is honoured, and the scoped decision
replaces the server-wide role rather than adding to it — so a narrow grant works, and a
Cluster-scoped one no longer confers authority beyond that cluster.
@@ -47,6 +37,15 @@ else
AlarmConfirm / AlarmShelve. A principal with
no grant at all can connect and see nothing.
+ With it off (the default): only Cluster-scoped grants
+ do anything, and they confer a server-wide role on their principal —
+ WriteOperate → writing any tag value;
+ AlarmAcknowledge → the Part 9 alarm methods
+ (acknowledge / confirm / shelve). Narrower grants are saved and shipped in every deployment
+ artifact but confer nothing. That under-granting is deliberate: honouring such
+ a grant's flags while its scope cannot be honoured would turn "WriteOperate on one
+ equipment" into server-wide write authority.
+
A node that merely leads to something you may browse stays browsable, or a narrow grant
could never be navigated to — so granting Browse deep in the tree also
reveals the names of the folders above it. Revoking a grant stops live subscriptions on the next
@@ -64,13 +63,23 @@ else
(the cluster, a namespace, an area, an equipment, a raw device, a tag). A principal is an API
key's principal, or the reserved anonymous principal that an
anonymous session authenticates as.
+
+ The Scope target column resolves each row against the current configuration. A
+ row marked unresolved points at nothing — it is shipped in
+ every deployment artifact but authorizes nobody, and the deploy gate rejects it. Open it and
+ re-pick the scope.
No ACL rows for this cluster — no principal holds WriteOperate or AlarmAck here, so writes and alarm acknowledges are refused. Reads, browses, subscriptions and history reads are unaffected.
+
+ No ACL rows for this cluster. With Security:Acl:Enabled off, that
+ means writes and alarm acknowledges are refused while reads, browses, subscriptions and
+ history reads stay open to any admitted session. With it on, it means
+ every data-plane operation is refused here — a client can connect and see nothing.
+
}
else
{
@@ -92,9 +101,29 @@ else
{
@a.NodeAclId
-
@a.Principal
+
+ @a.Principal
+ @if (_principals.TryGetValue(a.Principal, out var use) && !use.ReachesSomething)
+ {
+ no key
+ }
+
@a.ScopeKind
-
@(a.ScopeId ?? "—")
+
+ @(a.ScopeId ?? "—")
+ @if (_scopes.TryGetValue(a.NodeAclId, out var check))
+ {
+ @if (check.Ok)
+ {
+
@check.Message
+ }
+ else
+ {
+
unresolved
+
@check.Message
+ }
+ }
+
@foreach (var perm in PermissionChips(a.PermissionFlags))
{
@@ -115,6 +144,8 @@ else
@code {
[Parameter] public string ClusterId { get; set; } = "";
private List? _rows;
+ private Dictionary _scopes = new(StringComparer.Ordinal);
+ private Dictionary _principals = new(StringComparer.OrdinalIgnoreCase);
protected override async Task OnInitializedAsync()
{
@@ -123,6 +154,19 @@ else
.Where(a => a.ClusterId == ClusterId)
.OrderBy(a => a.NodeAclId)
.ToListAsync();
+
+ // Resolve every row's scope and principal so an unresolved grant is visible from the list rather
+ // than only at deploy time. One indexed lookup each; the distinct-principal grouping keeps the
+ // key lookups down to the number of distinct principals, not the number of rows.
+ foreach (var row in _rows)
+ {
+ _scopes[row.NodeAclId] = await Scopes.CheckScopeAsync(ClusterId, row.ScopeKind, row.ScopeId);
+ }
+
+ foreach (var principal in _rows.Select(r => r.Principal).Distinct(StringComparer.OrdinalIgnoreCase))
+ {
+ _principals[principal] = await Scopes.CheckPrincipalAsync(principal);
+ }
}
private static IEnumerable PermissionChips(ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodePermissions flags)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Acls/AclScopePickerModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Acls/AclScopePickerModal.razor
new file mode 100644
index 00000000..2f1c898c
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Acls/AclScopePickerModal.razor
@@ -0,0 +1,190 @@
+@* #520 stage 7 — pick a NodeAcl grant's scope from the real configuration.
+
+ The point of this modal is that it produces the ScopeKind and the ScopeId TOGETHER, from a node that
+ exists. Before it, the two were an unrelated dropdown and free-text box, so "RawDevice" + a TagId was a
+ well-formed row that saved, deployed, and resolved to nothing.
+
+ The trees are rooted at the grant's own cluster, so a cross-cluster scope is structurally unreachable
+ here — the picker's counterpart to the deploy gate's NodeAclScopeCrossCluster rule. *@
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
+@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
+@using ZB.MOM.WW.OtOpcUa.Core.Authorization
+@inject IAclScopeService Scopes
+
+@if (Visible)
+{
+
+
+
+
+
+
Choose scope · @ClusterId
+
+
+
+
+ Pick the node this grant applies to. Permissions inherit downward,
+ so a grant on a device covers every tag beneath it. Both trees are scoped to this
+ cluster, so a grant cannot be pointed at another cluster's node.
+
+
+ @if (_loading)
+ {
+
Loading…
+ }
+ else
+ {
+
+
+
Raw device tree
+
+
+ @if (_roots.Raw.Count == 0)
+ {
+
This cluster has no raw tree yet.
+ }
+ else
+ {
+
+ }
+
+
+
+
UNS tree
+
+
+ @if (_roots.Uns.Count == 0)
+ {
+
This cluster has no UNS areas yet.
+ }
+ else
+ {
+
+ }
+
+
+ UNS tags are not listed: a UNS signal is a reference to a raw
+ tag, and a Tag grant already covers the raw
+ node and every UNS projection of it. Pick the tag in the raw
+ tree.
+
+
+
+ }
+
+
+
+
+
+}
+
+@code {
+ /// Whether the modal is shown. The host owns this flag.
+ [Parameter] public bool Visible { get; set; }
+
+ /// The cluster the grant belongs to — both trees are rooted here.
+ [Parameter] public string ClusterId { get; set; } = "";
+
+ /// Raised with the chosen scope. The host reads ScopeKind + ScopeId off it.
+ [Parameter] public EventCallback OnPicked { get; set; }
+
+ /// Raised when the user cancels so the host can close.
+ [Parameter] public EventCallback OnCancel { get; set; }
+
+ private AclScopeRoots _roots = new(Array.Empty(), Array.Empty());
+ private AclScopeNode? _selected;
+ private bool _loading;
+ private bool _wasVisible;
+
+ // Whole-namespace rows. They are real scopes (NodeAclScopeKind.Namespace) with no entity behind them,
+ // so they are synthesized here rather than coming from a tree.
+ private readonly IReadOnlyList _rawHeader = new[]
+ {
+ new AclScopeNode
+ {
+ Key = "ns:raw",
+ DisplayName = "Every raw node",
+ Icon = "🌐",
+ ScopeKind = NodeAclScopeKind.Namespace,
+ ScopeId = NodeAclRealms.Raw,
+ PathLabel = "raw namespace",
+ },
+ };
+
+ private readonly IReadOnlyList _unsHeader = new[]
+ {
+ new AclScopeNode
+ {
+ Key = "ns:uns",
+ DisplayName = "Every UNS node",
+ Icon = "🌐",
+ ScopeKind = NodeAclScopeKind.Namespace,
+ ScopeId = NodeAclRealms.Uns,
+ PathLabel = "uns namespace",
+ },
+ };
+
+ protected override async Task OnParametersSetAsync()
+ {
+ // Reload only on the false→true transition, so a re-render while open does not wipe the
+ // expansion state the operator has built up.
+ if (Visible && !_wasVisible)
+ {
+ _selected = null;
+ _loading = true;
+ _roots = new AclScopeRoots(Array.Empty(), Array.Empty());
+ StateHasChanged();
+
+ _roots = string.IsNullOrWhiteSpace(ClusterId)
+ ? new AclScopeRoots(Array.Empty(), Array.Empty())
+ : await Scopes.LoadRootsAsync(ClusterId);
+
+ // The cluster root is the one node worth opening for the operator — everything else is a
+ // decision they should make deliberately.
+ foreach (var root in _roots.Raw) { root.Expanded = false; }
+ _loading = false;
+ }
+ _wasVisible = Visible;
+ }
+
+ private Task> LoadChildrenAsync(AclScopeNode node) =>
+ Scopes.LoadChildrenAsync(node);
+
+ private void Select(AclScopeNode node)
+ {
+ _selected = node;
+ StateHasChanged();
+ }
+
+ private async Task ConfirmAsync()
+ {
+ if (_selected is null) { return; }
+ await OnPicked.InvokeAsync(_selected);
+ }
+
+ private Task CancelAsync() => OnCancel.InvokeAsync();
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Acls/AclScopeTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Acls/AclScopeTree.razor
new file mode 100644
index 00000000..f48e4c8b
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Acls/AclScopeTree.razor
@@ -0,0 +1,118 @@
+@* #520 stage 7 — recursive, single-select renderer for the ACL scope picker.
+
+ Deliberately a separate component from RawTree rather than a third mode on it. RawTree already carries
+ the /raw editing tree AND the equipment reference picker; a third mode would put the scope picker's
+ bugs on the same page as device authoring, and this repo has no bUnit to catch that. This renders the
+ unified AclScopeNode, so ONE component covers both the raw and the UNS hierarchy. *@
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
+
+@foreach (var node in Nodes)
+{
+ @RenderNode(node, 0)
+}
+
+@code {
+ /// The rows to render at this level.
+ [Parameter, EditorRequired] public IReadOnlyList Nodes { get; set; } = default!;
+
+ /// The of the currently-selected row, if any.
+ [Parameter] public string? SelectedKey { get; set; }
+
+ /// Raised when a selectable row is chosen. Single-select: a grant has exactly one scope.
+ [Parameter] public EventCallback OnSelect { get; set; }
+
+ ///
+ /// Loads one lazy level (the raw side). Wired by the host to IAclScopeService.LoadChildrenAsync;
+ /// when absent, every node is treated as already materialised.
+ ///
+ [Parameter] public Func>>? LoadChildren { get; set; }
+
+ private RenderFragment RenderNode(AclScopeNode node, int depth) => __builder =>
+ {
+ var indent = $"padding-left:{depth * 18}px";
+ var hasExpander = node.HasLazyChildren || node.Children.Count > 0;
+ var selected = node.Selectable && node.Key == SelectedKey;
+
+
+ @if (hasExpander)
+ {
+
+ }
+ else
+ {
+
+ }
+
+ @if (node.Selectable)
+ {
+
+ }
+ else
+ {
+ @* Label only — an enterprise heading, or a level with no ACL scope kind behind it. *@
+
+ @node.Icon
+ @node.DisplayName
+
+ }
+
+
+ @if (node.Expanded && node.Loading)
+ {
+
+ Loading…
+
+ }
+ else if (node.Expanded && node.Error is not null)
+ {
+
@node.Error
+ }
+ else if (node.Expanded)
+ {
+ @foreach (var child in node.Children)
+ {
+ @RenderNode(child, depth + 1)
+ }
+ }
+ };
+
+ private async Task ToggleAsync(AclScopeNode node)
+ {
+ if (node.Loading) { return; }
+
+ node.Expanded = !node.Expanded;
+
+ if (node.Expanded && node.HasLazyChildren && !node.Loaded && LoadChildren is not null)
+ {
+ node.Loading = true;
+ node.Error = null;
+ StateHasChanged();
+ try
+ {
+ var children = await LoadChildren(node);
+ node.Children.Clear();
+ node.Children.AddRange(children);
+ node.Loaded = true;
+ }
+ catch (Exception ex)
+ {
+ node.Error = ex.Message;
+ }
+ finally
+ {
+ node.Loading = false;
+ }
+ }
+
+ StateHasChanged();
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
index 1759ac25..c0e33b27 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
@@ -72,6 +72,10 @@ public static class EndpointRouteBuilderExtensions
// UnsTreeService's reference/VirtualTag/ScriptedAlarm mutations (WP1); mirrors the
// deploy-time DraftValidator UnsEffectiveNameCollision rule.
services.AddScoped();
+ // #520 stage 7: the ACL scope picker's two trees + the authoring-time scope/principal checks that
+ // mirror the deploy gate's NodeAclScope* rules. Scoped, because it composes the two scoped tree
+ // services above.
+ services.AddScoped();
// WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
services.AddScoped();
services.AddSingleton();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor
index c696a484..9f81ba1d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor
@@ -7,6 +7,7 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.JSInterop
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Acls
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout
@using ZB.MOM.WW.OtOpcUa.Security.Auth
@using ZB.MOM.WW.Theme
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Acls/AclScopeServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Acls/AclScopeServiceTests.cs
new file mode 100644
index 00000000..aea9b6e8
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Acls/AclScopeServiceTests.cs
@@ -0,0 +1,379 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Acls;
+
+///
+/// #520 stage 7 — the ACL scope picker's trees and the authoring-time checks behind them.
+///
+///
+/// The defect this stage closes is not a crash: it is that a grant could be authored, saved, deployed
+/// and sealed green while resolving to nothing. So the tests that matter are the ones asserting a
+/// rejection — and each is paired with the neighbouring case that must still be accepted, or
+/// a service that rejected everything would pass the lot.
+///
+public sealed class AclScopeServiceTests
+{
+ // --- The picker's trees ------------------------------------------------------------------------
+
+ ///
+ /// The raw tree is rooted at the cluster and every level offers the scope kind that level grants
+ /// at. Without the kinds the picker would be a browser, not a picker.
+ ///
+ [Fact]
+ public async Task The_raw_tree_carries_a_scope_kind_at_every_level()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ var roots = await svc.LoadRootsAsync(AclScopeTestDb.ClusterId);
+
+ var cluster = roots.Raw.ShouldHaveSingleItem();
+ cluster.ScopeKind.ShouldBe(NodeAclScopeKind.Cluster);
+
+ var folder = (await svc.LoadChildrenAsync(cluster)).ShouldHaveSingleItem();
+ folder.ScopeKind.ShouldBe(NodeAclScopeKind.RawFolder);
+ folder.ScopeId.ShouldBe(AclScopeTestDb.FolderId);
+
+ var driver = (await svc.LoadChildrenAsync(folder)).ShouldHaveSingleItem();
+ driver.ScopeKind.ShouldBe(NodeAclScopeKind.RawDriver);
+ driver.ScopeId.ShouldBe(AclScopeTestDb.DriverId);
+
+ var device = (await svc.LoadChildrenAsync(driver)).ShouldHaveSingleItem();
+ device.ScopeKind.ShouldBe(NodeAclScopeKind.RawDevice);
+ device.ScopeId.ShouldBe(AclScopeTestDb.DeviceId);
+
+ var group = (await svc.LoadChildrenAsync(device)).ShouldHaveSingleItem();
+ group.ScopeKind.ShouldBe(NodeAclScopeKind.RawTagGroup);
+ group.ScopeId.ShouldBe(AclScopeTestDb.TagGroupId);
+
+ var tag = (await svc.LoadChildrenAsync(group)).ShouldHaveSingleItem();
+ tag.ScopeKind.ShouldBe(NodeAclScopeKind.Tag);
+ tag.ScopeId.ShouldBe(AclScopeTestDb.TagId);
+ }
+
+ ///
+ /// A cluster row produces NO scope id. The deploy gate rejects a Cluster grant that carries one
+ /// (NodeAclScopeIdUnexpected), so a picker that set the cluster id there would author a row
+ /// that cannot be deployed.
+ ///
+ [Fact]
+ public async Task The_cluster_row_produces_no_scope_id()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ var cluster = (await svc.LoadRootsAsync(AclScopeTestDb.ClusterId)).Raw.ShouldHaveSingleItem();
+
+ cluster.ScopeId.ShouldBeNull();
+ (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, cluster.ScopeKind!.Value, cluster.ScopeId))
+ .Ok.ShouldBeTrue();
+ }
+
+ ///
+ /// Another cluster's nodes are structurally unreachable through the picker — the counterpart of the
+ /// deploy gate's cross-cluster rule. Reachability, not just discouragement, is the point: the SIDE
+ /// driver exists and resolves, so only the rooting keeps it out.
+ ///
+ [Fact]
+ public async Task The_picker_cannot_reach_another_clusters_nodes()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ var roots = await svc.LoadRootsAsync(AclScopeTestDb.ClusterId);
+ var reachable = await FlattenAsync(svc, roots);
+
+ reachable.ShouldNotContain(AclScopeTestDb.OtherDriverId);
+
+ // …and it is a real driver, so the absence is the rooting rather than the id being unknown.
+ var side = await svc.LoadRootsAsync(AclScopeTestDb.OtherClusterId);
+ (await FlattenAsync(svc, side)).ShouldContain(AclScopeTestDb.OtherDriverId);
+ }
+
+ /// The UNS side offers area, line and equipment.
+ [Fact]
+ public async Task The_uns_tree_offers_area_line_and_equipment()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ var area = (await svc.LoadRootsAsync(AclScopeTestDb.ClusterId)).Uns.ShouldHaveSingleItem();
+ area.ScopeKind.ShouldBe(NodeAclScopeKind.UnsArea);
+ area.ScopeId.ShouldBe(AclScopeTestDb.AreaId);
+
+ var line = area.Children.ShouldHaveSingleItem();
+ line.ScopeKind.ShouldBe(NodeAclScopeKind.UnsLine);
+ line.ScopeId.ShouldBe(AclScopeTestDb.LineId);
+
+ var equipment = line.Children.ShouldHaveSingleItem();
+ equipment.ScopeKind.ShouldBe(NodeAclScopeKind.Equipment);
+ equipment.ScopeId.ShouldBe(AclScopeTestDb.EquipmentId);
+ }
+
+ ///
+ /// An unknown cluster yields empty trees rather than the whole fleet. A picker that fell back to
+ /// everything would hand an operator another cluster's nodes at the one moment they are least
+ /// likely to notice.
+ ///
+ [Fact]
+ public async Task An_unknown_cluster_yields_empty_trees()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ var roots = await svc.LoadRootsAsync("NO-SUCH-CLUSTER");
+
+ roots.Raw.ShouldBeEmpty();
+ roots.Uns.ShouldBeEmpty();
+ }
+
+ // --- The authoring-time scope check ------------------------------------------------------------
+
+ ///
+ /// THE test for this stage. Every scope kind resolves when pointed at its own entity type, and the
+ /// SAME id under a neighbouring kind does not — which is precisely the row the old free-text field
+ /// could produce, and which saved, deployed and authorized nobody.
+ ///
+ [Theory]
+ [InlineData(NodeAclScopeKind.RawFolder, AclScopeTestDb.FolderId)]
+ [InlineData(NodeAclScopeKind.RawDriver, AclScopeTestDb.DriverId)]
+ [InlineData(NodeAclScopeKind.RawDevice, AclScopeTestDb.DeviceId)]
+ [InlineData(NodeAclScopeKind.RawTagGroup, AclScopeTestDb.TagGroupId)]
+ [InlineData(NodeAclScopeKind.Tag, AclScopeTestDb.TagId)]
+ [InlineData(NodeAclScopeKind.UnsArea, AclScopeTestDb.AreaId)]
+ [InlineData(NodeAclScopeKind.UnsLine, AclScopeTestDb.LineId)]
+ [InlineData(NodeAclScopeKind.Equipment, AclScopeTestDb.EquipmentId)]
+ public async Task A_kind_resolves_its_own_entity_and_nothing_else(NodeAclScopeKind kind, string id)
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, kind, id))
+ .Ok.ShouldBeTrue($"{kind} '{id}' names a real entity of that type");
+
+ // The same id under a different kind must not resolve. RawDevice is the neighbour for every raw
+ // kind and UnsArea for every UNS one; both are real kinds with real rows, so a service that
+ // ignored the kind entirely would pass the line above and fail here.
+ var neighbour = kind is NodeAclScopeKind.UnsArea or NodeAclScopeKind.UnsLine or NodeAclScopeKind.Equipment
+ ? (kind == NodeAclScopeKind.UnsArea ? NodeAclScopeKind.UnsLine : NodeAclScopeKind.UnsArea)
+ : (kind == NodeAclScopeKind.RawDevice ? NodeAclScopeKind.RawDriver : NodeAclScopeKind.RawDevice);
+
+ (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, neighbour, id))
+ .Ok.ShouldBeFalse($"'{id}' is not a {neighbour}");
+ }
+
+ /// An id that names nothing is rejected, and the message says so.
+ [Fact]
+ public async Task A_scope_id_that_names_nothing_is_rejected()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ var check = await svc.CheckScopeAsync(
+ AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDevice, "DEV-typo");
+
+ check.Ok.ShouldBeFalse();
+ check.Message.ShouldContain("DEV-typo");
+ }
+
+ ///
+ /// A real id in ANOTHER cluster is rejected, and the message names the cluster that owns it. This
+ /// is the one rejection whose id exists, so a check that only asked "does a row with this id
+ /// exist?" would accept it and confer authority across a cluster boundary.
+ ///
+ [Fact]
+ public async Task A_scope_in_another_cluster_is_rejected_and_names_its_owner()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ var check = await svc.CheckScopeAsync(
+ AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDriver, AclScopeTestDb.OtherDriverId);
+
+ check.Ok.ShouldBeFalse();
+ check.Message.ShouldContain(AclScopeTestDb.OtherClusterId);
+
+ // Authored under its own cluster, the same row is fine — so the rejection is the boundary, not
+ // the driver.
+ (await svc.CheckScopeAsync(
+ AclScopeTestDb.OtherClusterId, NodeAclScopeKind.RawDriver, AclScopeTestDb.OtherDriverId))
+ .Ok.ShouldBeTrue();
+ }
+
+ ///
+ /// A row whose parent chain is broken exists but is owned by no cluster, so it resolves to no path.
+ /// Checked separately from a missing row because the two reach the rejection by different routes.
+ ///
+ [Fact]
+ public async Task An_entity_whose_parent_chain_is_broken_is_rejected()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ (await svc.CheckScopeAsync(
+ AclScopeTestDb.ClusterId, NodeAclScopeKind.Equipment, AclScopeTestDb.OrphanEquipmentId))
+ .Ok.ShouldBeFalse();
+
+ // The healthy equipment in the same table passes, so the rejection is the broken line and not the
+ // Equipment arm being wired to nothing.
+ (await svc.CheckScopeAsync(
+ AclScopeTestDb.ClusterId, NodeAclScopeKind.Equipment, AclScopeTestDb.EquipmentId))
+ .Ok.ShouldBeTrue();
+ }
+
+ ///
+ /// A Cluster grant covers everything and carries no id; one that carries an id means the author
+ /// picked the wrong kind and the grant is BROADER than intended — the direction that must never
+ /// pass silently.
+ ///
+ [Fact]
+ public async Task A_cluster_grant_carrying_a_scope_id_is_rejected()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.Cluster, AclScopeTestDb.DeviceId))
+ .Ok.ShouldBeFalse();
+ (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.Cluster, null))
+ .Ok.ShouldBeTrue();
+ }
+
+ /// Every kind but Cluster needs a target.
+ [Fact]
+ public async Task A_narrower_kind_without_a_scope_id_is_rejected()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDevice, null))
+ .Ok.ShouldBeFalse();
+ (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDevice, " "))
+ .Ok.ShouldBeFalse();
+ }
+
+ ///
+ /// The retired v2 kind is rejected whatever its target — including a target that would resolve
+ /// under the kind that replaced it, which is the shape an operator migrating a v2 row produces.
+ ///
+ [Fact]
+ public async Task The_retired_FolderSegment_kind_is_rejected()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ var check = await svc.CheckScopeAsync(
+ AclScopeTestDb.ClusterId, NodeAclScopeKind.FolderSegment, AclScopeTestDb.FolderId);
+
+ check.Ok.ShouldBeFalse();
+ check.Message.ShouldContain("RawFolder");
+ }
+
+ /// A Namespace grant takes a realm token, in either spelling, and nothing else.
+ [Theory]
+ [InlineData("raw", true)]
+ [InlineData("uns", true)]
+ [InlineData("RAW", true)]
+ [InlineData("https://zb.com/otopcua/uns", true)]
+ [InlineData("ns=2", false)]
+ [InlineData("MAIN", false)]
+ public async Task A_namespace_grant_accepts_only_a_realm(string scopeId, bool expected)
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.Namespace, scopeId))
+ .Ok.ShouldBe(expected);
+ }
+
+ /// An ordinal outside the enum comes from a hand-edited row; nothing below it is meaningful.
+ [Fact]
+ public async Task An_undefined_scope_kind_is_rejected()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, (NodeAclScopeKind)999, "anything"))
+ .Ok.ShouldBeFalse();
+ }
+
+ // --- Principals ---------------------------------------------------------------------------------
+
+ /// The reserved anonymous principal always reaches something — it has no key to count.
+ [Fact]
+ public async Task The_anonymous_principal_is_recognised_without_a_key()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ var usage = await svc.CheckPrincipalAsync(OpcUaPrincipals.Anonymous);
+
+ usage.IsAnonymous.ShouldBeTrue();
+ usage.ReachesSomething.ShouldBeTrue();
+ }
+
+ ///
+ /// Only enabled, unexpired keys count as reaching the principal. An expired key is the case a
+ /// count of rows would get wrong, so the fixture's stale principal has one and nothing else.
+ ///
+ [Fact]
+ public async Task Only_live_keys_count_towards_a_principal()
+ {
+ var svc = AclScopeTestDb.NewService(new FixedClock(AclScopeTestDb.Now));
+
+ var live = await svc.CheckPrincipalAsync(AclScopeTestDb.LivePrincipal);
+ live.EnabledKeys.ShouldBe(2);
+ live.DisabledKeys.ShouldBe(1);
+ live.ReachesSomething.ShouldBeTrue();
+
+ var stale = await svc.CheckPrincipalAsync(AclScopeTestDb.StalePrincipal);
+ stale.EnabledKeys.ShouldBe(0, "its only key expired yesterday");
+ stale.DisabledKeys.ShouldBe(1);
+ stale.ReachesSomething.ShouldBeFalse();
+ }
+
+ /// A principal nothing uses reaches nobody — the typo case the hint exists to surface.
+ [Fact]
+ public async Task An_unknown_principal_reaches_nobody()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ var usage = await svc.CheckPrincipalAsync("scadabridge-dcI"); // capital i, not lowercase L
+
+ usage.EnabledKeys.ShouldBe(0);
+ usage.ReachesSomething.ShouldBeFalse();
+ }
+
+ /// The suggestion list covers key principals and the reserved anonymous one.
+ [Fact]
+ public async Task Known_principals_include_every_key_principal_and_anonymous()
+ {
+ var svc = AclScopeTestDb.NewService();
+
+ var known = await svc.LoadKnownPrincipalsAsync();
+
+ known.ShouldContain(AclScopeTestDb.LivePrincipal);
+ known.ShouldContain(AclScopeTestDb.StalePrincipal);
+ known.ShouldContain(OpcUaPrincipals.Anonymous);
+ known.Count.ShouldBe(known.Distinct(StringComparer.OrdinalIgnoreCase).Count(), "three keys share a principal");
+ }
+
+ // --- Helpers ------------------------------------------------------------------------------------
+
+ ///
+ /// A clock pinned to the fixture's Now. Key expiry is the one thing here that moves with the
+ /// wall clock, and a test that let it would go red on its own a day after the fixture was written.
+ ///
+ private sealed class FixedClock(DateTime utcNow) : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow() => new(utcNow, TimeSpan.Zero);
+ }
+
+ /// Every scope id reachable through the picker for one cluster, both trees, fully expanded.
+ private static async Task> FlattenAsync(IAclScopeService svc, AclScopeRoots roots)
+ {
+ var ids = new List();
+
+ async Task WalkAsync(AclScopeNode node)
+ {
+ if (node.ScopeId is not null) ids.Add(node.ScopeId);
+
+ var children = node.HasLazyChildren
+ ? await svc.LoadChildrenAsync(node)
+ : node.Children;
+
+ foreach (var child in children) await WalkAsync(child);
+ }
+
+ foreach (var node in roots.Raw.Concat(roots.Uns)) await WalkAsync(node);
+ return ids;
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Acls/AclScopeTestDb.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Acls/AclScopeTestDb.cs
new file mode 100644
index 00000000..6a158125
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Acls/AclScopeTestDb.cs
@@ -0,0 +1,143 @@
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Acls;
+
+///
+/// Fixture for — two clusters, so cross-cluster scoping is testable, and
+/// one deliberately broken UNS chain, so "the row exists but no cluster owns it" is testable
+/// separately from "the row does not exist".
+///
+internal static class AclScopeTestDb
+{
+ public const string ClusterId = "MAIN";
+ public const string OtherClusterId = "SIDE";
+
+ public const string FolderId = "RF-plcs";
+ public const string DriverId = "DRV-modbus";
+ public const string DeviceId = "DEV-1";
+ public const string TagGroupId = "TG-motors";
+ public const string TagId = "TAG-speed";
+
+ public const string AreaId = "AREA-1";
+ public const string LineId = "LINE-1";
+ public const string EquipmentId = "EQ-1";
+
+ /// An equipment whose line does not exist — present, but owned by no cluster.
+ public const string OrphanEquipmentId = "EQ-orphan";
+
+ /// A driver in the OTHER cluster, for the cross-cluster rule.
+ public const string OtherDriverId = "DRV-side";
+
+ public const string LivePrincipal = "scadabridge-dcl";
+ public const string StalePrincipal = "retired-consumer";
+
+ /// The clock the fixture's key expiries are expressed against.
+ public static readonly DateTime Now = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
+
+ public static OtOpcUaConfigDbContext CreateNamed(string name) =>
+ new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options);
+
+ public static IDbContextFactory Factory(string name) => new NamedFactory(name);
+
+ /// Seeds a fresh uniquely-named InMemory database and returns a service over it.
+ public static AclScopeService NewService(TimeProvider? time = null)
+ {
+ var name = $"acl-{Guid.NewGuid():N}";
+ using (var db = CreateNamed(name)) { Seed(db); }
+
+ var factory = Factory(name);
+ return new AclScopeService(factory, new RawTreeService(factory), new UnsTreeService(factory), time);
+ }
+
+ public static void Seed(OtOpcUaConfigDbContext db)
+ {
+ db.ServerClusters.Add(new ServerCluster
+ {
+ ClusterId = ClusterId, Name = "Main", Enterprise = "zb", Site = "warsaw-west",
+ RedundancyMode = RedundancyMode.None, CreatedBy = "test",
+ });
+ db.ServerClusters.Add(new ServerCluster
+ {
+ ClusterId = OtherClusterId, Name = "Side", Enterprise = "zb", Site = "krakow",
+ RedundancyMode = RedundancyMode.None, CreatedBy = "test",
+ });
+
+ // MAIN raw: folder → driver → device → group → tag.
+ db.RawFolders.Add(new RawFolder
+ {
+ RawFolderId = FolderId, ClusterId = ClusterId, ParentRawFolderId = null, Name = "PLCs",
+ });
+ db.DriverInstances.Add(new DriverInstance
+ {
+ DriverInstanceId = DriverId, ClusterId = ClusterId, RawFolderId = FolderId,
+ Name = "modbus-1", DriverType = "Modbus", DriverConfig = "{}",
+ });
+ db.Devices.Add(new Device
+ {
+ DeviceId = DeviceId, DriverInstanceId = DriverId, Name = "dev1", DeviceConfig = "{}",
+ });
+ db.TagGroups.Add(new TagGroup
+ {
+ TagGroupId = TagGroupId, DeviceId = DeviceId, ParentTagGroupId = null, Name = "Motors",
+ });
+ db.Tags.Add(new Tag
+ {
+ TagId = TagId, DeviceId = DeviceId, TagGroupId = TagGroupId, Name = "speed",
+ DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}",
+ });
+
+ // SIDE raw: one driver, so a cross-cluster scope has something real to point at.
+ db.DriverInstances.Add(new DriverInstance
+ {
+ DriverInstanceId = OtherDriverId, ClusterId = OtherClusterId, RawFolderId = null,
+ Name = "s7-side", DriverType = "S7", DriverConfig = "{}",
+ });
+
+ // MAIN UNS: area → line → equipment.
+ db.UnsAreas.Add(new UnsArea { UnsAreaId = AreaId, ClusterId = ClusterId, Name = "Packaging" });
+ db.UnsLines.Add(new UnsLine { UnsLineId = LineId, UnsAreaId = AreaId, Name = "Line 1" });
+ db.Equipment.Add(new Equipment
+ {
+ EquipmentId = EquipmentId, EquipmentUuid = Guid.NewGuid(), UnsLineId = LineId,
+ Name = "packer-1", MachineCode = "packer_001",
+ });
+
+ // An equipment whose line was deleted: it exists, but nothing owns it.
+ db.Equipment.Add(new Equipment
+ {
+ EquipmentId = OrphanEquipmentId, EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-GONE",
+ Name = "stranded", MachineCode = "stranded_001",
+ });
+
+ db.ApiKeys.Add(Key("k1", LivePrincipal, enabled: true, expires: null));
+ db.ApiKeys.Add(Key("k2", LivePrincipal, enabled: true, expires: null));
+ db.ApiKeys.Add(Key("k3", LivePrincipal, enabled: false, expires: null));
+ db.ApiKeys.Add(Key("k4", StalePrincipal, enabled: true, expires: Now.AddDays(-1)));
+
+ db.SaveChanges();
+ }
+
+ private static ApiKey Key(string id, string principal, bool enabled, DateTime? expires) => new()
+ {
+ ApiKeyId = id,
+ Name = id,
+ SecretHash = "not-a-secret",
+ Principal = principal,
+ Enabled = enabled,
+ CreatedUtc = Now.AddDays(-30),
+ ExpiresUtc = expires,
+ };
+
+ private sealed class NamedFactory(string name) : IDbContextFactory
+ {
+ public OtOpcUaConfigDbContext CreateDbContext() => CreateNamed(name);
+
+ public Task CreateDbContextAsync(CancellationToken cancellationToken = default) =>
+ Task.FromResult(CreateNamed(name));
+ }
+}