merge: ACL scope model + role/identity fixes + four real test failures (#520 s3, #532, #533, #534)
v2-ci / build (push) Successful in 4m41s
v2-ci / unit-tests (push) Failing after 13m17s

Five commits, four Gitea issues closed.

#520 stage 3 — NodeScope becomes (ClusterId, Kind, Segments) with a variable-length
path, and AclScopePathResolver finally provides the scopePaths producer the trie
builder's required argument had never had. Closes two of the four documented
wire-up blockers: raw-realm nodes are now representable, and an unresolvable scope
is dropped with a diagnostic instead of planted where the walk cannot reach it.
Node ACLs remain unenforced — that is stages 5/6.

#532 — Cluster:Roles had no OTOPCUA_ROLES fallback despite two doc sites promising
one, so a node configured only through the env var joined the cluster with no Akka
roles: invisible to every role query, un-deployable, and silent about it. The field
fix on wonder-app-vd03 was a registry edit. Now applied once, in AddOtOpcUaCluster,
plus the four open-coded ClusterId derivations collapsed into RoleParser.

#533 — three string-identity rules in one authorization path, working only because
both sides held the same NodeAcl instances. One rule now: identifiers compare
OrdinalIgnoreCase, path segments compare Ordinal.

#534 — both libplctag drivers carried Port on their create-params and consumed it
nowhere, so libplctag substituted 44818 and the connection went to whatever answers
there. A production defect: on a plant network that is another PLC, read as Good.

Also fixed: three integration failures that had stopped catching what they guard —
a harness that stopped spawning the redundancy singleton after the Phase 6 re-home
(48s timeout, both nodes "role unknown"), a hand-written driver-type list that
could not see a driver registered without a probe, and an AbCip probe config still
in the retired v2 shape, exercising the fallback its own comment says it avoids.
The AbLegacy PCCC fixture was deployed for the first time, one port per family.

Full solution suite green, zero failures — AdminUI.Tests 969, Host.IntegrationTests
211/4 skipped, AbLegacy integration 4/4 (never passed before).

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 02:37:47 -04:00
58 changed files with 4078 additions and 501 deletions
+6 -2
View File
@@ -104,7 +104,11 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only
| `Port` | int | `4053` | Cluster transport port. **Duplicated as `ClusterNode.AkkaPort` in the Config DB** — see the note below. |
| `PublicHostname` | string | `127.0.0.1` | Hostname advertised in cluster gossip; must be reachable by peers. **Duplicated as `ClusterNode.Host`** — see the note below. |
| `SeedNodes` | string[] | `[]` | Seed nodes for bootstrapping. **Ordered:** a node that appears in its own list must be entry 0 (only `seed-nodes[0]` can form a new cluster), or the host refuses to start — see [Redundancy.md § Bootstrap: self-first seed ordering](Redundancy.md#bootstrap-self-first-seed-ordering). **Per-pair since Phase 6:** each application `Cluster`'s pair is its own Akka mesh, so a node's `SeedNodes` lists only itself (first) and its own pair partner (second) — never a node from another cluster's pair. |
| `Roles` | string[] | `[]` | Cluster roles for this node. When empty, falls back to `OTOPCUA_ROLES`. Allowed values: `admin`, `driver`, `dev`, plus a per-cluster `cluster-{ClusterId}` role (e.g. `cluster-MAIN`, `cluster-SITE-A`) — see [Per-cluster mesh split (Phase 6)](#per-cluster-mesh-split-phase-6-cluster-clusterid-roles--splittopologytransportvalidator) below. |
| `Roles` | string[] | `[]` | Cluster roles for this node. When empty, falls back to `OTOPCUA_ROLES` (see the note below). Allowed values: `admin`, `driver`, `dev`, plus a per-cluster `cluster-{ClusterId}` role (e.g. `cluster-MAIN`, `cluster-SITE-A`) — see [Per-cluster mesh split (Phase 6)](#per-cluster-mesh-split-phase-6-cluster-clusterid-roles--splittopologytransportvalidator) below. |
> **`Cluster:Roles` and `OTOPCUA_ROLES` are two knobs for one thing, and only one of them is the Akka role list.** `OTOPCUA_ROLES` drives all of `Program.cs`'s conditional wiring (which subsystems mount); `Cluster:Roles` becomes the node's **gossiped Akka member roles**. Since Gitea #532 `AddOtOpcUaCluster` applies the documented fallback — an empty `Cluster:Roles` takes the env var — so setting only `OTOPCUA_ROLES` now works. Before that fix it did not, silently: the node joined the mesh carrying **no roles at all**, which reads as healthy locally and as *"no driver-role nodes are Up"* at central, and every deployment to it timed out ([`deployments/wonder-app-vd03-makino-z-34184.md`](deployments/wonder-app-vd03-makino-z-34184.md)).
>
> Setting `Cluster:Roles` explicitly is still the recommendation, because the two sources normalize differently: `Cluster:Roles` binds **verbatim** while the env path is **lower-cased** by `RoleParser.Parse`. Akka compares role names ordinally, so a redundant pair configured through *different* sources with a mixed-case `cluster-{ClusterId}` (e.g. `cluster-SITE-A` vs `cluster-site-a`) would gossip two different role names and elect two singletons. Configure both nodes of a pair the same way.
> The full redundancy model (ServiceLevel tiers, split-brain, peer discovery) is in [`Redundancy.md`](Redundancy.md). The OPC UA peer-URI advertising lives in the `OpcUa:PeerApplicationUris` key above.
@@ -329,7 +333,7 @@ All names are read in this repo's source via `Environment.GetEnvironmentVariable
| Variable | Read by | Effect / default |
|---|---|---|
| `OTOPCUA_ROLES` | `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (`RoleParser.Parse`) | Comma-separated cluster roles for the node (`admin`, `driver`, `dev`). Drives the conditional wiring and the per-role appsettings overlay. Used when `Cluster:Roles` is empty. |
| `OTOPCUA_ROLES` | `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (`RoleParser.Parse`) and `AddOtOpcUaCluster`'s `AkkaClusterOptions` post-configure (`RoleParser.ResolveEffectiveRoles`) | Comma-separated cluster roles for the node (`admin`, `driver`, `dev`, `cluster-{ClusterId}`). Drives the conditional wiring and the per-role appsettings overlay, and — when `Cluster:Roles` is empty — the node's Akka member roles too. See the note under `Cluster:Roles` for why the two sources are not interchangeable. |
| `OTOPCUA_CONFIG_CONNECTION` | `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/DesignTimeDbContextFactory.cs` (design-time / `dotnet ef` only) | Read at **design time** by `DesignTimeDbContextFactory.cs` for `dotnet ef` migrations. At **runtime** the server resolves the connection string from `ConnectionStrings:ConfigDb` (env form: `ConnectionStrings__ConfigDb`) via `configuration.GetConnectionString("ConfigDb")` in `ServiceCollectionExtensions.cs` — `OTOPCUA_CONFIG_CONNECTION` appears there only as a hint in an error message, not via `GetEnvironmentVariable`. No credential is embedded in source. |
| `ASPNETCORE_ENVIRONMENT` | ASP.NET host builder (framework) | Selects `appsettings.{Environment}.json` (e.g. `Development`). |
@@ -56,6 +56,19 @@ now joins **UP with roles ADMIN+DRIVER** and deployments seal. Prior env backed
> Product follow-up: either wire `OTOPCUA_ROLES` → `AkkaClusterOptions.Roles`, or bake `Cluster:Roles`
> into the deploy template, so a node redeploy doesn't regress to role-less.
> **RESOLVED 2026-08-01 (Gitea #532).** The first option shipped: `AddOtOpcUaCluster` post-configures
> `AkkaClusterOptions`, so an empty `Cluster:Roles` now falls back to `OTOPCUA_ROLES` through
> `RoleParser.ResolveEffectiveRoles`. A node in this box's original configuration would join **UP with
> roles ADMIN+DRIVER** without the registry edit above, so a redeploy that drops the hand-added
> `Cluster__Roles__0/1` no longer regresses to role-less. The `Cluster__Roles` entries are still worth
> keeping — the two sources normalize case differently (see `docs/Configuration.md` → `Cluster:Roles`),
> and an explicit list is the one that survives someone changing the env var for a different reason.
>
> Worth recording for the next incident of this shape: this was **documented behaviour that did not
> exist**. `AkkaClusterOptions.Roles`' XML doc and `SplitTopologyTransportValidator` both described the
> fallback, so anyone reading the code to diagnose this box would have concluded the roles were already
> being applied and looked elsewhere.
## Live verification (OtOpcUa CLI client → server)
`connect` + `browse` confirm the equipment + both tags are served at the node IDs above.
@@ -78,9 +78,9 @@ string; only what produces that string changes.
| # | #520's blocker | Resolution under this design |
|---|---|---|
| 1 | Raw-realm nodes have no representable `NodeScope` (`NodeHierarchyKind` has one member, `Equipment`) | **Add `NodeHierarchyKind.Raw`**, whose path is the RawPath segments `Folder/Driver/Device/TagGroup/Tag`. `NodeAclScopeKind` already carries `Namespace` and `FolderSegment` — `FolderSegment` becomes matchable for the first time, at raw-folder level. This is the one genuine **design** item, not wiring. |
| 1 | Raw-realm nodes have no representable `NodeScope` (`NodeHierarchyKind` has one member, `Equipment`) | **Add `NodeHierarchyKind.Raw`.** ⚠️ **This cell was wrong about the shape** — it described the path as the five-tuple `Folder/Driver/Device/TagGroup/Tag`, but folders and tag groups both self-nest, so the real path is `folder… / Driver / Device / group… / Tag` with unbounded depth on two axes. The fix was therefore not an enum member but a variable-depth `NodeScope` + segment-loop walk. `FolderSegment` was **not** repurposed as the raw-folder level; it is retired, and `RawFolder`/`RawDriver`/`RawDevice`/`RawTagGroup` were appended instead. Correctly identified as the one genuine **design** item. |
| 2 | Session carries roles, not LDAP groups | **Dissolved by D1** — the principal comes from the key, not from a directory. |
| 3 | `PermissionTrieBuilder`'s `scopePaths` has no producer | Built node-side from the artifact's UNS hierarchy + raw tree. Note every existing evaluator unit test runs in the no-`scopePaths` "deterministic test mode", so **the production hierarchy path is currently untested** — it needs its own coverage, not just a producer. |
| 3 | `PermissionTrieBuilder`'s `scopePaths` has no producer | **Done in stage 3** — `AclScopePathResolver`, built node-side from the artifact's raw + UNS topology (no DB read, so a driver-only node can build it). The coverage note was right in substance but imprecise: six tests *did* pass a `scopePaths` dictionary, but every one hand-wrote it in the test body with exactly the segments the walker expected, so nothing pinned a producer — and there was none to pin. That gap is closed by an artifact → parse → build → walk test. |
| 4 | No session lifecycle / `AuthGenerationId` | Stamp the generation from the artifact `RevisionHash` at session activation; a new deploy invalidates cached decisions. `UserAuthorizationState` already has the freshness predicates. |
Plus, from the same audit: the node never parses ACLs out of the artifact, and `OtOpcUaNodeManager`
@@ -99,11 +99,18 @@ mismatch).
## Smaller inconsistencies to settle en route
- `NodeAclScopeKind.FolderSegment` is offered in the authoring dropdown but the trie walker has no
level to match it at — fixed by blocker #1's `Raw` kind.
level to match it at — **settled in stage 3, but not the way this line assumed.** It is a retired v2
concept (it described the `SystemPlatform`/Galaxy hierarchy v3 deleted), so rather than being given a
level it is now explicitly unresolvable, with `RawFolder` as its v3 replacement. It survives in the
enum only because the artifact stores the ordinal, so removing it would renumber every later member
in every deployed artifact. The dropdown labels it retired, and the deploy gate names it.
⚠️ The old claim that the walker could not match it was slightly overstated: `Build` routed it through
`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.
- `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference `Core` at all — the evaluator is not even
on the node manager's compile-time reference graph.
- ~~`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.
## Stages
@@ -115,8 +122,55 @@ Each stage is one commit, builds clean, and leaves the tree shippable.
2. ✅ **Identity cutover.** `ApiKeyOpcUaUserAuthenticator` replaces `LdapOpcUaUserAuthenticator` on the
OPC UA data plane; Anonymous resolves to the `anonymous` principal or is rejected per setting.
AdminUI LDAP untouched.
3. **Raw scope.** *(not started — sequenced after stage 4 at the owner's request)* `NodeHierarchyKind.Raw` + RawPath scope paths; `scopePaths` producer built from the
artifact; coverage for the production hierarchy path that today has none.
3. **Raw scope.** ✅ **Done.** `NodeHierarchyKind.Raw`, a variable-depth scope model, the `scopePaths`
producer (`AclScopePathResolver`) fed from the artifact, a deploy-time gate for ACL scopes, and the
first coverage of the production hierarchy path.
**It was substantially larger than this line originally implied, because the sketch had the shape of
the raw tree wrong.** The plan said the Raw path is the five-tuple
`Folder/Driver/Device/TagGroup/Tag`. It is not: `RawFolder.ParentRawFolderId` and
`TagGroup.ParentTagGroupId` are both self-referencing, so folders and tag groups each nest without
bound and a real RawPath is `folder… / Driver / Device / group… / Tag`. `NodeScope` was fixed-arity
(five nullable level fields) and `PermissionTrie.CollectMatches` was five hardcoded steps, so the
scope model could not address a real raw tree at all. `NodeScope` is now `{ClusterId, Kind,
IReadOnlyList<string> Segments}` and the walk is a segment loop. Good news found en route: the trie
*structure* was already variable-depth (`NodeAclPath.Segments` is a list; `PermissionTrieNode.Children`
is a recursive dictionary), so only the walker and the scope record had to change.
Four decisions worth keeping:
- **The namespace level became the realm.** Level 1 of the walk gated on `NodeScope.NamespaceId`, but
v3 **has no `Namespace` entity** — the snapshot was retired when the address space went
dual-namespace, and the identifier appeared nowhere in production outside `NodeScope`. So level 1
could only ever fail to match, which silently made *every* sub-cluster grant unreachable while
cluster grants kept working. It is now the realm token (`raw`/`uns`), derived from the scope's
`Kind` by `NodeAclRealms` rather than carried on it, so the walk and the path producer cannot
disagree about its spelling. `NodeAclScopeKind.Namespace` becomes meaningful again as "a whole
realm".
- **Provenance comes from the granting row's `ScopeKind`**, recorded on `TrieGrant` at build time,
not from the depth the grant sits at. Depth-derived provenance could not name a raw level at all
(the depth of "Device" varies with folder nesting) and misreported any path whose length disagreed
with its kind.
- **The trie comparer moved from `OrdinalIgnoreCase` to `Ordinal`.** Raw sibling-name uniqueness is
ordinal, so `Speed` and `speed` can both exist under one device and a grant on either would have
matched both — a silent widening. Case-sensitive is also the fail-closed direction.
- **The null-`scopePaths` "deterministic test mode" is deleted.** It planted unresolvable rows at a
direct child of the trie root, a position the walker cannot reach — so the grant looked placed and
could never match, and every test using that mode proved nothing about production placement. An
unresolvable row is now dropped with a diagnostic, and `scopePaths` is required.
A scope resolves to a **list** of paths, not one: a raw tag projected into N equipments is reachable
at N+1 NodeIds and a write through any of them lands on the same driver reference, so a Tag-scoped
grant that covered only the raw node would be bypassable through a UNS one. The lookup is keyed on
`(ScopeKind, ScopeId)` rather than `ScopeId` alone, because `ScopeId` has no FK and no type
discriminator — ids are prefixed only by convention, and an id collision across entity types would
hand a grant the wrong node's paths.
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.
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
+29 -10
View File
@@ -337,16 +337,21 @@ until the tracking issue below is closed.
**The four things blocking a wire-up** (all verified, none of them merely "call the evaluator"):
1. **Raw-realm nodes have no representable scope.** `NodeHierarchyKind` has exactly one member,
`Equipment`. Raw NodeIds are `Folder→Driver→Device→TagGroup→Tag` RawPaths, which `NodeScope` cannot
express — yet the write gate is attached to raw tags. Roughly half the writable address space has
no scope to evaluate. This is a design decision, not wiring.
1. ~~**Raw-realm nodes have no representable scope.**~~ **Closed by #520 stage 3.** `NodeScope` is now
`(ClusterId, Kind, Segments)` with a variable-length path, and `NodeHierarchyKind` carries both
`Uns` and `Raw`. The previous fixed-arity shape could not address a real raw tree at all — folder
chains and tag-group chains both nest without bound — so it silently capped at five levels and
mislabelled every one of them. Four `Raw*` scope kinds were appended for authoring.
2. ~~**The identity carries roles, not groups.**~~ **Dissolved by #520 stage 2.** The column is now
`NodeAcl.Principal` and the principal comes from the presented API key (or the reserved `anonymous`),
not from a directory — so there is no group list to lose.
3. **`PermissionTrieBuilder`'s `scopePaths` argument has no producer.** Without it every sub-cluster
grant lands in the builder's fallback bucket — the hazard its own comments warn about. It would have
to be derived from the artifact's UNS relations.
3. ~~**`PermissionTrieBuilder`'s `scopePaths` argument has no producer.**~~ **Closed by #520 stage 3.**
`AclScopePathResolver` is that producer, built on both sides of the split — EF entities at the deploy
gate, artifact JSON on the node — and `scopePaths` is now a required argument with no fallback: an
unresolvable scope is **dropped with a diagnostic** rather than planted at a trie position the walk
can never reach. A scope resolves to *several* paths when one entity is reachable at several NodeIds
(a raw tag projected into N equipments has one raw path plus N UNS paths), because a grant that
covered only some of them would be bypassable through the one it missed.
4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but
nothing in production constructs one per session. **Half-closed by stage 4:** the *generation* now
exists and advances — `DataPlaneAuthorityState` stamps a monotonic `GenerationId` each time an
@@ -354,9 +359,23 @@ until the tracking issue below is closed.
the stamping is stage 5's job, because until an enforcement seam reads it, a per-session state object
would be constructed and never consulted.
Two smaller inconsistencies inside the subsystem itself: `NodeAclScopeKind.FolderSegment` is offered in
the authoring dropdown but the trie walker has no level to match it at, and `NodePermissions.AlarmRead`
has no `OpcUaOperation` mapping, so it is grantable but unreachable.
`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
`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
`OpcUaOperation` mapping, so it is grantable but unreachable.
**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** —
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.
### Hierarchy (design)
@@ -48,10 +48,26 @@ public sealed class AkkaClusterOptions
public string[] SeedNodes { get; set; } = Array.Empty<string>();
/// <summary>
/// Cluster roles for this node. When empty the role list comes from
/// <c>OTOPCUA_ROLES</c> via <see cref="RoleParser"/>. Allowed values:
/// <c>admin</c>, <c>driver</c>, <c>dev</c>.
/// Cluster roles for this node. Allowed values: <c>admin</c>, <c>driver</c>, <c>dev</c>, plus one
/// per-cluster <c>cluster-{ClusterId}</c> role (per-cluster mesh, Phase 6).
/// </summary>
/// <remarks>
/// <para>
/// When this is empty the list falls back to the <c>OTOPCUA_ROLES</c> environment variable,
/// applied by <c>AddOtOpcUaCluster</c> through
/// <see cref="RoleParser.ResolveEffectiveRoles(IReadOnlyList{string}?)"/>. That fallback was
/// described here — and relied upon in the field — for a year before it was implemented; a
/// node configured only through the env var joined the cluster carrying no roles at all.
/// See Gitea #532 and <c>docs/deployments/wonder-app-vd03-makino-z-34184.md</c>.
/// </para>
/// <para>
/// The two sources normalize differently: values bound from <c>Cluster:Roles</c> are taken
/// verbatim, while the environment path is lower-cased by <see cref="RoleParser.Parse"/>.
/// Akka compares gossiped role names ordinally, so <b>configure both nodes of a redundant
/// pair through the same source</b> — a pair split across the two with a mixed-case
/// <c>cluster-{ClusterId}</c> would gossip two different role names and elect two singletons.
/// </para>
/// </remarks>
public string[] Roles { get; set; } = Array.Empty<string>();
/// <summary>
@@ -39,22 +39,26 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
// sharing a bind IP) stay distinct. Production hosts have unique DNS names so the port
// suffix is harmless redundancy.
_localNode = CommonsNodeId.Parse($"{options.Value.PublicHostname}:{options.Value.Port}");
_localRoles = new HashSet<string>(options.Value.Roles, StringComparer.Ordinal);
// OrdinalIgnoreCase (Gitea #532): Cluster:Roles binds verbatim, so a node authored with 'Driver'
// used to answer HasRole("driver") false while gossiping a role Akka would still match against
// its own ordinal comparison — the node was a driver to the cluster and not to itself.
_localRoles = new HashSet<string>(options.Value.Roles, StringComparer.OrdinalIgnoreCase);
// Derived from the node's OWN configured roles (not live Cluster.State) so this identity is
// available at host-build time, before the cluster forms. Iterate the ORIGINAL array — not
// _localRoles — because first-wins requires configuration order, which HashSet does not
// guarantee.
var clusterRoles = options.Value.Roles.Where(RoleParser.IsClusterRole).ToArray();
if (clusterRoles.Length > 0)
// guarantee. The OTOPCUA_ROLES fallback is already applied to the bound options by
// AddOtOpcUaCluster's PostConfigure, so nothing is re-resolved here.
var clusterRoles = RoleParser.ClusterRolesIn(options.Value.Roles);
if (clusterRoles.Count > 0)
{
_clusterRole = clusterRoles[0];
_clusterId = RoleParser.ClusterIdFromRole(_clusterRole);
if (clusterRoles.Length > 1)
if (clusterRoles.Count > 1)
{
_logger.LogWarning(
"Node configured with {Count} cluster-scoped roles ({Roles}); using the first ({ClusterRole})",
clusterRoles.Length, string.Join(", ", clusterRoles), _clusterRole);
clusterRoles.Count, string.Join(", ", clusterRoles), _clusterRole);
}
}
@@ -93,11 +93,15 @@ public sealed class ConfigSourceOptionsValidator : IValidateOptions<ConfigSource
// Per-cluster mesh Phase 4: a driver-only node has no central ConfigDb connection to fall back
// on, so Direct is only ever valid on a fused admin+driver node (admin holds the SQL
// connection). Roles come from Cluster:Roles directly, not IClusterRoleInfo — see the ctor
// remark.
var roles = _configuration.GetSection("Cluster:Roles").Get<string[]>() ?? Array.Empty<string>();
var isDriver = Array.IndexOf(roles, "driver") >= 0;
var isAdmin = Array.IndexOf(roles, "admin") >= 0;
// connection). Roles come from configuration, not IClusterRoleInfo — see the ctor remark — and
// through RoleParser.ResolveEffectiveRoles so the OTOPCUA_ROLES fallback applies (Gitea #532).
// Reading Cluster:Roles alone exempted the exact node this rule exists for: an env-only
// driver-only node has no ConfigDb (Program.cs gates it on the same env var) yet saw an empty
// role list here and sailed through on Direct, to read its configuration from nothing.
var roles = RoleParser.ResolveEffectiveRoles(
_configuration.GetSection("Cluster:Roles").Get<string[]>());
var isDriver = Array.IndexOf(roles, RoleParser.Driver) >= 0;
var isAdmin = Array.IndexOf(roles, RoleParser.Admin) >= 0;
if (isDriver && !isAdmin && isDirect)
{
@@ -23,15 +23,110 @@ public static class RoleParser
/// <summary>Returns true when <paramref name="role"/> is a cluster-scoped role — starts with
/// <see cref="ClusterRolePrefix"/> and carries a non-empty ClusterId suffix.</summary>
/// <remarks>
/// The prefix test is <b>case-insensitive</b> (Gitea #532). It was ordinal, which meant a
/// <c>Cluster:Roles</c> entry spelled <c>Cluster-SITE-A</c> — that path binds verbatim, unlike
/// the <see cref="Parse"/> path, which lower-cases — classified as an ordinary role. The node
/// then resolved a <see langword="null"/> ClusterId while still gossiping a cluster-scoped role,
/// so it looked split-topology to its peers and un-scoped to itself.
/// <see cref="SplitTopologyTransportValidator"/> pre-normalized for exactly this reason;
/// <c>ClusterRoleInfo</c> did not, and got the null.
/// </remarks>
/// <param name="role">The role name to test.</param>
public static bool IsClusterRole(string role) =>
role.StartsWith(ClusterRolePrefix, StringComparison.Ordinal) && role.Length > ClusterRolePrefix.Length;
role is not null
&& role.StartsWith(ClusterRolePrefix, StringComparison.OrdinalIgnoreCase)
&& role.Length > ClusterRolePrefix.Length;
/// <summary>Extracts the ClusterId from a cluster-scoped role, e.g. <c>cluster-SITE-A</c> → <c>SITE-A</c>.
/// Assumes <see cref="IsClusterRole"/> is true for <paramref name="role"/>.</summary>
/// <remarks>
/// The suffix keeps the operator's own spelling. It is NOT case-normalized here because the two
/// role sources disagree by construction — <c>Cluster:Roles</c> binds verbatim
/// (<c>cluster-SITE-A</c> ⇒ <c>SITE-A</c>) while <see cref="Parse"/> lower-cases the
/// <c>OTOPCUA_ROLES</c> path (⇒ <c>site-a</c>) — and normalizing one side would silently rename
/// the Akka role of a running deployment (docker-dev's rig configures <c>cluster-MAIN</c> /
/// <c>cluster-SITE-A</c> verbatim today). Every consumer of a ClusterId therefore compares it
/// case-insensitively; see the identity rule on <c>AclScopeRef</c> (Gitea #533).
/// </remarks>
/// <param name="role">The cluster-scoped role name.</param>
public static string ClusterIdFromRole(string role) => role[ClusterRolePrefix.Length..];
/// <summary>
/// The cluster-scoped roles in <paramref name="roles"/>, in the order given. More than one is a
/// misconfiguration the caller is expected to report — a node belongs to exactly one mesh.
/// </summary>
/// <param name="roles">The node's roles.</param>
/// <returns>The cluster-scoped roles, original spelling preserved.</returns>
public static IReadOnlyList<string> ClusterRolesIn(IEnumerable<string>? roles) =>
roles is null ? Array.Empty<string>() : roles.Where(IsClusterRole).ToArray();
/// <summary>The first cluster-scoped role in <paramref name="roles"/>, or null when there is none.</summary>
/// <param name="roles">The node's roles.</param>
/// <returns>The cluster role, e.g. <c>cluster-SITE-A</c>.</returns>
public static string? ClusterRoleFromRoles(IEnumerable<string>? roles) =>
roles?.FirstOrDefault(IsClusterRole);
/// <summary>
/// The ClusterId this node belongs to, derived from its own roles; null when it carries no
/// cluster-scoped role (a legacy / single-mesh node).
/// </summary>
/// <remarks>
/// The single home for a derivation that was open-coded at four call sites before Gitea #532
/// (<c>ClusterRoleInfo</c>, the ControlPlane singleton scope, <c>SplitTopologyTransportValidator</c>
/// and <c>ConfigDbApiKeyStore</c>) — each with its own view of whether the classification was
/// case-sensitive, which is how the mixed-case gap above survived in three of the four.
/// </remarks>
/// <param name="roles">The node's roles.</param>
/// <returns>The ClusterId, or <see langword="null"/>.</returns>
public static string? ClusterIdFromRoles(IEnumerable<string>? roles)
{
var role = ClusterRoleFromRoles(roles);
return role is null ? null : ClusterIdFromRole(role);
}
/// <summary>
/// Resolves a node's <b>effective</b> Akka roles: <c>Cluster:Roles</c> when it is non-empty,
/// otherwise the <c>OTOPCUA_ROLES</c> environment variable through <see cref="Parse"/>.
/// </summary>
/// <remarks>
/// <para>
/// ⚠️ <b>This fallback was documented for a year before it existed</b> (Gitea #532).
/// <c>AkkaClusterOptions.Roles</c>' own XML doc and <see cref="SplitTopologyTransportValidator"/>
/// both described it; only the validator implemented it, privately. A node configured solely
/// through <c>OTOPCUA_ROLES</c> — which drives every bit of <c>Program.cs</c>'s conditional
/// wiring, so it reads as the authoritative knob — joined the cluster carrying <b>no Akka
/// roles at all</b>. It ran, served, and logged nothing wrong, while central reported "no
/// driver-role nodes are Up" and every deployment to it timed out. That is not hypothetical:
/// see <c>docs/deployments/wonder-app-vd03-makino-z-34184.md</c>, where the field fix was to
/// hand-add <c>Cluster__Roles__0/1</c> to a Windows service's registry environment.
/// </para>
/// <para>
/// <b>Configured wins outright</b> — a stray <c>OTOPCUA_ROLES</c> can never widen an explicit
/// <c>Cluster:Roles</c>. The two sources normalize differently on purpose (see
/// <see cref="ClusterIdFromRole"/>), so a redundant pair should be configured through the
/// same one.
/// </para>
/// </remarks>
/// <param name="configuredRoles">The bound <c>Cluster:Roles</c> value.</param>
/// <param name="envRoles">The raw <c>OTOPCUA_ROLES</c> value.</param>
/// <returns>The effective roles; empty when neither source names any.</returns>
/// <exception cref="ArgumentException">The environment value names an unknown role.</exception>
public static string[] ResolveEffectiveRoles(IReadOnlyList<string>? configuredRoles, string? envRoles)
{
if (configuredRoles is { Count: > 0 }) return [.. configuredRoles];
return Parse(envRoles);
}
/// <summary>Reads the effective roles for the current process, resolving the environment itself.</summary>
/// <param name="configuredRoles">The bound <c>Cluster:Roles</c> value.</param>
/// <returns>The effective roles.</returns>
public static string[] ResolveEffectiveRoles(IReadOnlyList<string>? configuredRoles) =>
ResolveEffectiveRoles(configuredRoles, Environment.GetEnvironmentVariable(RolesEnvVar));
/// <summary>The environment variable the role list falls back to when <c>Cluster:Roles</c> is empty.</summary>
public const string RolesEnvVar = "OTOPCUA_ROLES";
/// <summary>Parses a comma-separated string of role names into a validated array.</summary>
/// <param name="raw">The raw role string to parse.</param>
/// <returns>The distinct, lower-cased, validated role names.</returns>
@@ -34,6 +34,22 @@ public static class ServiceCollectionExtensions
services.AddValidatedOptions<AkkaClusterOptions, AkkaClusterOptionsValidator>(
configuration, AkkaClusterOptions.SectionName);
// Gitea #532: apply the OTOPCUA_ROLES fallback the Roles property has always DOCUMENTED, in the
// one place every consumer reads through. Before this, a node configured only through the env
// var — the knob that drives all of Program.cs's role wiring — bound an EMPTY Roles array and so
// joined the cluster role-less: invisible to RoleLeader/MembersWithRole, ineligible for every
// role-scoped singleton, and un-deployable ("no driver-role nodes are Up") with nothing in its
// own logs to say so. Doing it here rather than at each call site means BuildClusterOptions
// (Akka's own member roles), ClusterRoleInfo, the ControlPlane singleton scope and
// ConfigDbApiKeyStore's ClusterId all see the same answer.
//
// PostConfigure, not a bind trick: it runs after the section binds and before ValidateOnStart,
// so the validators below see the effective list. An unknown role in the env var throws out of
// RoleParser.Parse — the same throw Program.cs already takes on the same value, so an invalid
// env var cannot reach here on the production path in the first place.
services.PostConfigure<AkkaClusterOptions>(
options => options.Roles = RoleParser.ResolveEffectiveRoles(options.Roles));
// Per-cluster mesh Phase 2: which transport carries central→node commands. Validated at
// startup for the same reason the cluster options are — a contact point that cannot resolve
// produces silence, not an error, and silence is indistinguishable from "nothing to do".
@@ -61,17 +61,17 @@ public sealed class SplitTopologyTransportValidator : IValidateOptions<MeshTrans
// Resolve the EFFECTIVE Akka roles exactly as the node does, or this "fail loud" rule fires on
// the wrong role view and passes silently on the two shapes it exists to catch:
// (a) Roles-source divergence. AkkaClusterOptions.Roles binds Cluster:Roles but falls back to
// OTOPCUA_ROLES (via RoleParser.Parse — the same call Program.cs makes) when Cluster:Roles
// is empty. A node configured with only OTOPCUA_ROLES=driver,cluster-SITE-A genuinely
// carries the cluster role in Akka, so it must be validated too.
// OTOPCUA_ROLES when that is empty. A node configured with only
// OTOPCUA_ROLES=driver,cluster-SITE-A genuinely carries the cluster role in Akka, so it
// must be validated too. Until Gitea #532 this validator was the ONLY code that applied
// that fallback — it is now RoleParser.ResolveEffectiveRoles, shared with the
// AkkaClusterOptions PostConfigure that finally makes the option itself agree.
// (b) Case. The Cluster:Roles bind path is verbatim (no lowercasing), whereas RoleParser.Parse
// lowercases the OTOPCUA_ROLES path. Normalize BOTH here (trim + ToLowerInvariant) so
// 'Cluster-SITE-A' / 'Driver' / 'Admin' cannot slip past the ordinal IsClusterRole /
// driver / admin checks.
// 'Driver' / 'Admin' cannot slip past the ordinal driver / admin checks. (IsClusterRole
// itself is case-insensitive as of #532; the trim is still this rule's own business.)
var configured = _configuration.GetSection("Cluster:Roles").Get<string[]>() ?? Array.Empty<string>();
var effective = configured.Length > 0
? configured
: RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
var effective = RoleParser.ResolveEffectiveRoles(configured);
var normalized = Array.ConvertAll(effective, r => r.Trim().ToLowerInvariant());
@@ -118,8 +118,12 @@ public sealed class TelemetryOptionsValidator : IValidateOptions<TelemetryOption
+ $"Expected '{TelemetryOptions.ModeDps}' or '{TelemetryOptions.ModeGrpc}'.");
}
var roles = _configuration.GetSection("Cluster:Roles").Get<string[]>() ?? Array.Empty<string>();
var isDriver = Array.IndexOf(roles, "driver") >= 0;
// Through ResolveEffectiveRoles so the OTOPCUA_ROLES fallback applies (Gitea #532) — a node
// configured only through the env var is still a driver node and must satisfy the Grpc-mode
// requirements below.
var roles = RoleParser.ResolveEffectiveRoles(
_configuration.GetSection("Cluster:Roles").Get<string[]>());
var isDriver = Array.IndexOf(roles, RoleParser.Driver) >= 0;
if (isGrpc)
{
@@ -1,18 +1,62 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>ACL scope level. Per <c>acl-design.md</c> §"Scope Hierarchy".</summary>
/// <remarks>
/// ⚠️ <b>Append new members only — never insert.</b> SQL Server stores this as a string
/// (<c>nvarchar(16)</c>, so a member name must also stay ≤ 16 characters), but the deployment
/// artifact serialises it as the <b>integer ordinal</b> and the node-side decoder reads it back
/// with <c>Enum.IsDefined</c>. Inserting a member mid-enum silently re-maps every grant in every
/// already-deployed artifact to a different scope level.
/// </remarks>
public enum NodeAclScopeKind
{
/// <summary>The whole cluster — the only kind that carries a null <c>ScopeId</c>.</summary>
Cluster,
Namespace,
UnsArea,
UnsLine,
Equipment,
/// <summary>
/// A folder segment anchored below a namespace (used for FolderPath-scoped tags).
/// Distinguishes folder grants from UNS <see cref="Equipment"/> grants in the
/// <c>AuthorizationDecision.Provenance</c> audit trail and Admin UI diagnostics.
/// One whole OPC UA namespace (realm). <c>ScopeId</c> is the realm token <c>raw</c> or
/// <c>uns</c>; see <c>NodeAclRealms</c>, which is the single authority for that spelling.
/// </summary>
Namespace,
/// <summary>A UNS area. <c>ScopeId</c> is a <c>UnsArea.UnsAreaId</c>.</summary>
UnsArea,
/// <summary>A UNS line. <c>ScopeId</c> is a <c>UnsLine.UnsLineId</c>.</summary>
UnsLine,
/// <summary>A UNS equipment. <c>ScopeId</c> is an <c>Equipment.EquipmentId</c>.</summary>
Equipment,
/// <summary>
/// ⚠️ <b>Retired v2 concept — do not author new rows with this kind.</b> It described a folder
/// segment under the v2 <c>SystemPlatform</c>/Galaxy hierarchy, which v3 deleted along with the
/// <c>WalkSystemPlatform</c> walker. The v3 replacement is <see cref="RawFolder"/>. The member
/// survives only because removing it would shift every later ordinal in already-deployed
/// artifacts; a row still carrying it resolves no scope path and is reported as an unresolved
/// scope by the deploy gate.
/// </summary>
FolderSegment,
/// <summary>
/// A single tag. <c>ScopeId</c> is a <c>Tag.TagId</c> — i.e. a <b>raw</b> tag, which is the only
/// kind of tag v3 has. Because one raw tag can be projected into several equipments by
/// <c>UnsTagReference</c> rows, a Tag-scoped grant resolves to <b>several</b> trie paths: the raw
/// node plus every UNS projection of it. That is deliberate — a write routes to the same driver
/// reference through either NodeId, so a grant that covered only one of them would be bypassable
/// through the other.
/// </summary>
Tag,
/// <summary>A raw-tree folder. <c>ScopeId</c> is a <c>RawFolder.RawFolderId</c>. (v3; appended.)</summary>
RawFolder,
/// <summary>A raw-tree driver instance. <c>ScopeId</c> is a <c>DriverInstance.DriverInstanceId</c>. (v3; appended.)</summary>
RawDriver,
/// <summary>A raw-tree device. <c>ScopeId</c> is a <c>Device.DeviceId</c>. (v3; appended.)</summary>
RawDevice,
/// <summary>A raw-tree tag group. <c>ScopeId</c> is a <c>TagGroup.TagGroupId</c>. (v3; appended.)</summary>
RawTagGroup,
}
@@ -62,6 +62,10 @@ public sealed class DraftSnapshot
/// <summary>Gets the list of poll groups.</summary>
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
/// <summary>ACL grants. Drive the scope-resolution rule: a scope that names nothing deployed is
/// silently dropped when the permission trie is built, so the grant has to be caught here instead.</summary>
public IReadOnlyList<NodeAcl> NodeAcls { get; init; } = [];
/// <summary>Prior Equipment rows (any generation, same cluster) for stability checks.</summary>
public IReadOnlyList<Equipment> PriorEquipment { get; init; } = [];
@@ -52,6 +52,7 @@ public static class DraftSnapshotFactory
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().ToListAsync(ct),
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
ActiveReservations = await db.ExternalIdReservations
.AsNoTracking()
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -42,9 +43,241 @@ public static class DraftValidator
ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors);
ValidateSqlConnectionStringNotPersisted(draft, errors);
ValidateNodeAclScopes(draft, errors);
return errors;
}
/// <summary>
/// #520 stage 3: every <see cref="NodeAcl"/> row's scope must name something the deployed address
/// space actually contains.
/// </summary>
/// <remarks>
/// <para>
/// ACL rows have had <b>no</b> validation anywhere until now, and the failure mode is the worst
/// shape a security control can have: <c>AclScopePathResolver</c> returns no path for a scope it
/// cannot resolve, the trie builder then drops the grant with a build-time diagnostic, and the
/// deploy still seals green. An operator who mistypes a <c>ScopeId</c>, or scopes a grant at an
/// entity someone later deleted, gets a row that reads as a live grant in the AdminUI and
/// authorizes nothing at runtime. Silently dropping a grant is fail-closed and therefore safe,
/// but it is not <em>visible</em> — which is why the check belongs at the deploy gate, where the
/// author is still standing in front of the mistake.
/// </para>
/// <para>
/// <b>The realm tokens are duplicated here, deliberately.</b> <c>NodeAclRealms</c> in
/// <c>Core.Authorization</c> is the authority for their spelling, but <c>Core</c> references
/// <c>Configuration</c> and not the reverse, so this project cannot reach it. The two namespace
/// <em>URIs</em> come from <see cref="V3NodeIds"/> in <c>Commons</c>, which both sides do share;
/// only the two short tokens are literals. If a third realm is ever added, it must be added in
/// both places — the accepted cost of not inverting the project graph for one gate.
/// </para>
/// <para>
/// Existence is checked with the same <b>case-insensitive, untrimmed</b> comparison
/// <c>AclScopePathResolver</c>'s dictionary lookups use, so a row this rule passes is a row that
/// resolver resolves. The realm check mirrors <c>NodeAclRealms.TryCanonicalize</c> instead
/// (trimmed, case-insensitive) for the same reason. Both this rule and the resolver were
/// ordinal until Gitea #533; the cross-cluster comparison below was ordinal too, which was the
/// worse half — it could reject a deploy as cross-cluster over a <c>ClusterId</c> the runtime
/// would have matched, and the two spellings genuinely occur (see
/// <c>RoleParser.ClusterIdFromRole</c>). The rule now is: identifiers compare
/// <c>OrdinalIgnoreCase</c>, path segments compare <c>Ordinal</c>; it is stated in full on
/// <c>AclScopeRef</c>.
/// </para>
/// <para>
/// <b>Containment is checked as well as existence.</b> A grant in cluster A naming an entity that
/// belongs to cluster B is not the "names nothing" failure — it resolves to a real trie path, and
/// so it silently confers authority across a cluster boundary. It gets its own code because the
/// remedy is different: an unresolved id is a typo or a deletion, a cross-cluster id is a grant
/// authored against the wrong cluster. The lookups are deliberately <b>fleet-wide, not
/// cluster-filtered</b> — a per-cluster lookup would make a cross-cluster id present as merely
/// unresolved, which is the wrong diagnosis and would leave this check unable to ever fire.
/// </para>
/// <para>
/// Ownership is not a uniform property. <c>RawFolder</c>, <c>DriverInstance</c> and <c>UnsArea</c>
/// carry <c>ClusterId</c>; <c>Device</c>, <c>TagGroup</c>, <c>Tag</c>, <c>UnsLine</c> and
/// <c>Equipment</c> do not, and inherit it through their parent. Both chains are at most three
/// hops and neither can loop (<c>TagGroup</c> and <c>RawFolder</c> nest, but both reach the
/// cluster through a non-nesting link — <c>DeviceId</c> and <c>ClusterId</c> respectively), so
/// unlike the resolver's ancestry walks these need no depth guard. A broken chain means the
/// owning cluster is unknowable, which is the same state as unresolvable: it reports
/// <c>NodeAclScopeUnresolved</c> and never both codes.
/// </para>
/// </remarks>
private static void ValidateNodeAclScopes(DraftSnapshot draft, List<ValidationError> errors)
{
if (draft.NodeAcls.Count == 0) return;
// One map per ancestry link, built once for the whole pass. Their key sets double as the existence
// sets, so "does this id name a row of the kind's type" and "which cluster owns it" cannot disagree.
// GroupBy/First rather than ToDictionary: a duplicated id is another rule's business, and throwing
// here would take down every remaining check in the same pass.
var folderCluster = Link(draft.RawFolders, f => f.RawFolderId, f => f.ClusterId);
var driverCluster = Link(draft.DriverInstances, d => d.DriverInstanceId, d => d.ClusterId);
var deviceDriver = Link(draft.Devices, d => d.DeviceId, d => d.DriverInstanceId);
var groupDevice = Link(draft.TagGroups, g => g.TagGroupId, g => g.DeviceId);
var tagDevice = Link(draft.Tags, t => t.TagId, t => t.DeviceId);
var areaCluster = Link(draft.UnsAreas, a => a.UnsAreaId, a => a.ClusterId);
var lineArea = Link(draft.UnsLines, l => l.UnsLineId, l => l.UnsAreaId);
var equipLine = Link(draft.Equipment, e => e.EquipmentId, e => e.UnsLineId);
static Dictionary<string, string> Link<T>(
IReadOnlyList<T> rows, Func<T, string> key, Func<T, string> value) =>
rows.GroupBy(key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => value(g.First()), StringComparer.OrdinalIgnoreCase);
// Owning cluster, or null when a link in the chain is dangling. Direct-ClusterId kinds bottom out
// immediately; the rest hop through their parent.
string? ClusterOfDevice(string deviceId) =>
deviceDriver.TryGetValue(deviceId, out var driverId) ? driverCluster.GetValueOrDefault(driverId) : null;
string? ClusterOfLine(string lineId) =>
lineArea.TryGetValue(lineId, out var areaId) ? areaCluster.GetValueOrDefault(areaId) : null;
foreach (var acl in draft.NodeAcls)
{
var id = acl.NodeAclId;
// An ordinal outside the enum comes from a hand-edited row or a schema the code no longer
// matches. Every later check switches on the kind, so nothing below it would be meaningful.
if (!Enum.IsDefined(acl.ScopeKind))
{
errors.Add(new("NodeAclScopeKindUndefined",
$"NodeAcl '{id}' has ScopeKind ordinal {(int)acl.ScopeKind}, which is not a defined " +
"NodeAclScopeKind; the row cannot be evaluated. Re-author it with a valid scope kind.",
id));
continue;
}
if (acl.ScopeKind == NodeAclScopeKind.Cluster)
{
// Cluster is the one kind with nothing to point at, so a ScopeId here means the author
// intended a narrower scope and picked the wrong kind — the grant is cluster-wide either
// way, i.e. broader than intended, which is the direction that must never pass silently.
if (!string.IsNullOrWhiteSpace(acl.ScopeId))
errors.Add(new("NodeAclScopeIdUnexpected",
$"NodeAcl '{id}' is Cluster-scoped but carries ScopeId '{acl.ScopeId}'. A Cluster grant " +
"covers the whole cluster and ignores ScopeId; clear it, or change ScopeKind to the level " +
"the id names.",
id));
continue;
}
// Ahead of the missing-ScopeId check on purpose: the retirement is the actionable fact, and
// telling someone to supply a ScopeId for a kind that can never resolve sends them the wrong way.
if (acl.ScopeKind == NodeAclScopeKind.FolderSegment)
{
errors.Add(new("NodeAclScopeKindRetired",
$"NodeAcl '{id}' uses the retired v2 scope kind FolderSegment, which has no v3 entity behind " +
"it and can never resolve, whatever its ScopeId. Re-scope the grant to RawFolder (the v3 " +
"replacement) and point ScopeId at the RawFolderId that replaced the old folder segment.",
id));
continue;
}
if (string.IsNullOrWhiteSpace(acl.ScopeId))
{
errors.Add(new("NodeAclScopeIdMissing",
$"NodeAcl '{id}' has ScopeKind {acl.ScopeKind} but no ScopeId. Only Cluster grants may omit " +
"it; every other kind resolves no scope path without one and the grant is dropped at build " +
"time. Set the ScopeId, or change ScopeKind to Cluster.",
id));
continue;
}
var scopeId = acl.ScopeId!;
if (acl.ScopeKind == NodeAclScopeKind.Namespace)
{
if (CanonicalRealm(scopeId) is null)
errors.Add(new("NodeAclNamespaceInvalid",
$"NodeAcl '{id}' is Namespace-scoped but ScopeId '{scopeId}' names no OPC UA namespace. " +
$"Use '{RealmRaw}' or '{RealmUns}' (or the full URI '{V3NodeIds.RawNamespaceUri}' / " +
$"'{V3NodeIds.UnsNamespaceUri}').",
id));
continue;
}
// Each arm names the entity type the kind's ScopeId is drawn from, whether a row with that id
// exists at all, and which cluster owns it. Switch arms evaluate lazily, so only the matched
// kind's chain is walked.
var (entity, exists, owner) = acl.ScopeKind switch
{
NodeAclScopeKind.UnsArea =>
("UnsArea", areaCluster.ContainsKey(scopeId), areaCluster.GetValueOrDefault(scopeId)),
NodeAclScopeKind.UnsLine =>
("UnsLine", lineArea.ContainsKey(scopeId), ClusterOfLine(scopeId)),
NodeAclScopeKind.Equipment =>
("Equipment", equipLine.ContainsKey(scopeId),
equipLine.TryGetValue(scopeId, out var eqLine) ? ClusterOfLine(eqLine) : null),
NodeAclScopeKind.Tag =>
("Tag", tagDevice.ContainsKey(scopeId),
tagDevice.TryGetValue(scopeId, out var tagDev) ? ClusterOfDevice(tagDev) : null),
NodeAclScopeKind.RawFolder =>
("RawFolder", folderCluster.ContainsKey(scopeId), folderCluster.GetValueOrDefault(scopeId)),
NodeAclScopeKind.RawDriver =>
("DriverInstance", driverCluster.ContainsKey(scopeId), driverCluster.GetValueOrDefault(scopeId)),
NodeAclScopeKind.RawDevice =>
("Device", deviceDriver.ContainsKey(scopeId), ClusterOfDevice(scopeId)),
NodeAclScopeKind.RawTagGroup =>
("TagGroup", groupDevice.ContainsKey(scopeId),
groupDevice.TryGetValue(scopeId, out var grpDev) ? ClusterOfDevice(grpDev) : null),
// Cluster / Namespace / FolderSegment all returned above; the undefined case did too.
_ => (string.Empty, true, acl.ClusterId),
};
if (!exists)
{
errors.Add(new("NodeAclScopeUnresolved",
$"NodeAcl '{id}' is scoped at {acl.ScopeKind} '{scopeId}', which is not a {entity} in this " +
"configuration. The grant would be dropped at build time and authorize nothing. Correct the " +
"ScopeId, or delete the grant if the entity was removed.",
id));
continue;
}
if (owner is null)
{
// The row exists but its parent chain is dangling, so it has no place in any cluster's tree
// and resolves to no path — the same outcome as a missing row, reported under the same code.
errors.Add(new("NodeAclScopeUnresolved",
$"NodeAcl '{id}' is scoped at {acl.ScopeKind} '{scopeId}', which exists but whose parent " +
$"chain is broken, so no cluster owns it and it resolves to no scope path. Repair the {entity}'s " +
"parent reference, or delete the grant.",
id));
continue;
}
if (!string.Equals(owner, acl.ClusterId, StringComparison.OrdinalIgnoreCase))
errors.Add(new("NodeAclScopeCrossCluster",
$"NodeAcl '{id}' is a grant in cluster '{acl.ClusterId}' but is scoped at {acl.ScopeKind} " +
$"'{scopeId}', which belongs to cluster '{owner}'. The id is real, so the grant would resolve " +
"and confer authority across a cluster boundary. Re-author the grant under cluster " +
$"'{owner}', or point ScopeId at the matching {entity} in '{acl.ClusterId}'.",
id));
}
}
// Realm tokens — see ValidateNodeAclScopes for why these are duplicated rather than referenced.
// Core.Authorization.NodeAclRealms is the authority; keep the two in step.
private const string RealmRaw = "raw";
private const string RealmUns = "uns";
/// <summary>Mirror of <c>NodeAclRealms.TryCanonicalize</c>: accepts either short realm token or either
/// full namespace URI, trimmed and case-insensitive. Null when the id names no realm.</summary>
private static string? CanonicalRealm(string scopeId)
{
var trimmed = scopeId.Trim();
if (string.Equals(trimmed, RealmRaw, StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, V3NodeIds.RawNamespaceUri, StringComparison.OrdinalIgnoreCase))
return RealmRaw;
if (string.Equals(trimmed, RealmUns, StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, V3NodeIds.UnsNamespaceUri, StringComparison.OrdinalIgnoreCase))
return RealmUns;
return null;
}
/// <summary>
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
/// at the deploy gate (Gitea #498).
@@ -0,0 +1,351 @@
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// Identifies a scope for path lookup. Keyed on the kind <b>as well as</b> the id.
/// </summary>
/// <remarks>
/// <para>
/// The kind is part of the key because <c>NodeAcl.ScopeId</c> is a bare string with no foreign key
/// and no type discriminator of its own — ids are prefixed by convention (<c>RF…</c>, <c>EQ…</c>)
/// but nothing enforces it. Keying on the id alone would let a <c>DeviceId</c> that happened to
/// equal a <c>UnsAreaId</c> hand a grant the other entity's paths, which is a silent
/// wrong-node authorization rather than a visible error.
/// </para>
/// <para>
/// <b>The string-identity rule for the whole authorization path (Gitea #533), stated here because
/// this type is where it was first violated:</b>
/// </para>
/// <list type="bullet">
/// <item>
/// <b>Identifiers compare <c>OrdinalIgnoreCase</c></b> — <c>ScopeId</c>, <c>ClusterId</c>,
/// <c>Principal</c>. They are opaque handles, not addresses; two spellings of one id always
/// mean the same entity, and the sources genuinely differ in case (a <c>ClusterId</c> arrives
/// lower-cased through <c>OTOPCUA_ROLES</c> and verbatim through <c>Cluster:Roles</c> — see
/// <c>RoleParser.ClusterIdFromRole</c> — and a <c>ScopeId</c> is operator free-text in the ACL
/// editor until the stage-7 picker lands).
/// </item>
/// <item>
/// <b>Path segments compare <c>Ordinal</c></b> — every level of <see cref="NodeAclPath"/>,
/// and therefore <c>PermissionTrieNode.Children</c>. They are OPC UA browse names, where
/// <c>Speed</c> and <c>speed</c> are two different nodes that can coexist under one device
/// (<c>RawPaths.Comparer</c> is ordinal, so raw sibling-name uniqueness is too).
/// </item>
/// </list>
/// <para>
/// Before #533 this record struct used the compiler's default equality, i.e. ordinal, while the
/// trie builder filtered <c>ClusterId</c> and hashed principals <c>OrdinalIgnoreCase</c> — three
/// rules in one path. It worked only because the resolver and the builder were handed the same
/// <c>NodeAcl</c> instances, so the <c>ScopeId</c> strings were literally the same object. Feed
/// the two sides from different snapshots — which is the entire reason this resolver is
/// input-shape agnostic, EF on the gate and artifact JSON on the node — and any case drift drops
/// <b>every</b> grant on that scope. Fail-closed and diagnosed, but a silent loss of authority.
/// </para>
/// </remarks>
/// <param name="Kind">The authored scope kind.</param>
/// <param name="ScopeId">The authored scope id.</param>
public readonly record struct AclScopeRef(NodeAclScopeKind Kind, string ScopeId)
{
/// <summary>Equality over the kind plus a case-insensitive id — see the rule on the type.</summary>
/// <param name="other">The other reference.</param>
/// <returns><see langword="true"/> when both name the same scope.</returns>
public bool Equals(AclScopeRef other) =>
Kind == other.Kind && string.Equals(ScopeId, other.ScopeId, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc />
public override int GetHashCode() =>
HashCode.Combine(Kind, StringComparer.OrdinalIgnoreCase.GetHashCode(ScopeId ?? string.Empty));
}
/// <summary>
/// Produces the <c>scopePaths</c> lookup <see cref="PermissionTrieBuilder.Build"/> consumes: for each
/// ACL scope, the trie path(s) at which a grant on it must be planted (#520 stage 3).
/// </summary>
/// <remarks>
/// <para>
/// Pure and input-shape agnostic, in the same spirit as <see cref="RawPathResolver"/>: callers
/// build the ancestry maps from whatever they hold — EF entities on the authoring / deploy-gate
/// side, artifact JSON on the node side — so both sides produce identical paths without sharing a
/// database. That matters because a driver-only node has no ConfigDb at all (per-cluster mesh
/// Phase 4), so anything requiring a DB read node-side is disqualified outright.
/// </para>
/// <para>
/// Raw tag paths are delegated to <see cref="RawPathResolver"/> rather than recomputed, so an ACL
/// path and the RawPath that forms the node's actual NodeId cannot drift apart. Container paths
/// (folder / driver / device / group) are composed here from the same chains in the same order,
/// which keeps every container path a true prefix of the tags beneath it — the property the trie
/// walk depends on for a container grant to cascade.
/// </para>
/// <para>
/// A broken or dangling link yields <b>no path</b> for that scope, never a throw: the grant is then
/// dropped by the builder with an <see cref="PermissionTrieBuildDiagnosticReason.UnresolvedScope"/>
/// diagnostic. Fail-closed, and consistent with the resolver family's "a malformed artifact drops
/// one entry rather than faulting the whole decode" rule.
/// </para>
/// </remarks>
public sealed class AclScopePathResolver
{
private readonly IReadOnlyDictionary<string, (string? ParentId, string Name)> _folders;
private readonly IReadOnlyDictionary<string, (string? RawFolderId, string Name)> _drivers;
private readonly IReadOnlyDictionary<string, (string DriverInstanceId, string Name)> _devices;
private readonly IReadOnlyDictionary<string, (string? ParentId, string DeviceId, string Name)> _groups;
private readonly IReadOnlyDictionary<string, (string DeviceId, string? TagGroupId, string Name)> _tags;
private readonly IReadOnlyDictionary<string, string> _areas;
private readonly IReadOnlyDictionary<string, (string UnsAreaId, string Name)> _lines;
private readonly IReadOnlyDictionary<string, (string UnsLineId, string Name)> _equipment;
private readonly IReadOnlyList<(string EquipmentId, string TagId, string EffectiveName)> _references;
private readonly RawPathResolver _rawPaths;
/// <summary>Initializes a new <see cref="AclScopePathResolver"/> over the deployed topology.</summary>
/// <param name="folders"><c>RawFolderId → (ParentRawFolderId?, Name)</c>.</param>
/// <param name="drivers"><c>DriverInstanceId → (RawFolderId?, Name)</c>.</param>
/// <param name="devices"><c>DeviceId → (DriverInstanceId, Name)</c>.</param>
/// <param name="groups"><c>TagGroupId → (ParentTagGroupId?, DeviceId, Name)</c>.</param>
/// <param name="tags"><c>TagId → (DeviceId, TagGroupId?, Name)</c>.</param>
/// <param name="areas"><c>UnsAreaId → Name</c>.</param>
/// <param name="lines"><c>UnsLineId → (UnsAreaId, Name)</c>.</param>
/// <param name="equipment"><c>EquipmentId → (UnsLineId, Name)</c>.</param>
/// <param name="references">The UNS projections: <c>(EquipmentId, TagId, EffectiveName)</c>.</param>
public AclScopePathResolver(
IReadOnlyDictionary<string, (string? ParentId, string Name)> folders,
IReadOnlyDictionary<string, (string? RawFolderId, string Name)> drivers,
IReadOnlyDictionary<string, (string DriverInstanceId, string Name)> devices,
IReadOnlyDictionary<string, (string? ParentId, string DeviceId, string Name)> groups,
IReadOnlyDictionary<string, (string DeviceId, string? TagGroupId, string Name)> tags,
IReadOnlyDictionary<string, string> areas,
IReadOnlyDictionary<string, (string UnsAreaId, string Name)> lines,
IReadOnlyDictionary<string, (string UnsLineId, string Name)> equipment,
IReadOnlyList<(string EquipmentId, string TagId, string EffectiveName)> references)
{
ArgumentNullException.ThrowIfNull(folders);
ArgumentNullException.ThrowIfNull(drivers);
ArgumentNullException.ThrowIfNull(devices);
ArgumentNullException.ThrowIfNull(groups);
ArgumentNullException.ThrowIfNull(tags);
ArgumentNullException.ThrowIfNull(areas);
ArgumentNullException.ThrowIfNull(lines);
ArgumentNullException.ThrowIfNull(equipment);
ArgumentNullException.ThrowIfNull(references);
// Every map is re-keyed OrdinalIgnoreCase rather than used as handed in. Ids are identifiers, so
// they follow the identifier half of the rule on AclScopeRef (Gitea #533) — and doing it HERE,
// rather than documenting a comparer contract for callers, is what makes the rule impossible to
// get wrong from the outside: the two producers (EF entities at the deploy gate, artifact JSON on
// the node) build these dictionaries independently and neither can now diverge from the other.
_folders = ById(folders);
_drivers = ById(drivers);
_devices = ById(devices);
_groups = ById(groups);
_tags = ById(tags);
_areas = ById(areas);
_lines = ById(lines);
_equipment = ById(equipment);
_references = references;
// The projected group map goes in with the same comparer, so a raw ACL path and the RawPath that
// forms the node's NodeId still agree segment-for-segment — RawPathResolver emits each entity's
// OWN name, so a case-insensitive id lookup widens which chains resolve and never what they spell.
_rawPaths = new RawPathResolver(
_folders,
_drivers,
_devices,
ById(groups.ToDictionary(kv => kv.Key, kv => (kv.Value.ParentId, kv.Value.Name))));
}
/// <summary>
/// Resolve the trie path(s) for every distinct scope named by <paramref name="rows"/>. Scopes that
/// resolve to nothing are absent from the result, which is what makes the builder drop them.
/// </summary>
/// <param name="rows">The ACL rows whose scopes need resolving.</param>
/// <returns>The <c>scopePaths</c> lookup.</returns>
public IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> Resolve(IReadOnlyList<NodeAcl> rows)
{
ArgumentNullException.ThrowIfNull(rows);
var result = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
foreach (var row in rows)
{
if (row.ScopeKind == NodeAclScopeKind.Cluster || string.IsNullOrEmpty(row.ScopeId)) continue;
var key = new AclScopeRef(row.ScopeKind, row.ScopeId);
if (result.ContainsKey(key)) continue;
var paths = ResolveOne(row.ScopeKind, row.ScopeId);
if (paths.Count > 0) result[key] = paths;
}
return result;
}
/// <summary>Resolve one scope to its trie path(s). Empty when the scope names nothing deployed.</summary>
/// <param name="kind">The scope kind.</param>
/// <param name="scopeId">The scope id.</param>
/// <returns>The resolved paths, possibly empty.</returns>
public IReadOnlyList<NodeAclPath> ResolveOne(NodeAclScopeKind kind, string scopeId)
{
if (string.IsNullOrEmpty(scopeId)) return [];
switch (kind)
{
case NodeAclScopeKind.Namespace:
var realm = NodeAclRealms.TryCanonicalize(scopeId);
return realm is null ? [] : [new NodeAclPath([realm])];
case NodeAclScopeKind.UnsArea:
return _areas.TryGetValue(scopeId, out var areaName)
? [UnsPath([areaName])]
: [];
case NodeAclScopeKind.UnsLine:
return TryUnsLineSegments(scopeId) is { } lineSegs ? [UnsPath(lineSegs)] : [];
case NodeAclScopeKind.Equipment:
return TryEquipmentSegments(scopeId) is { } eqSegs ? [UnsPath(eqSegs)] : [];
case NodeAclScopeKind.Tag:
return ResolveTag(scopeId);
case NodeAclScopeKind.RawFolder:
return TryFolderChain(scopeId) is { } folderSegs ? [RawPath(folderSegs)] : [];
case NodeAclScopeKind.RawDriver:
return TryDriverSegments(scopeId) is { } driverSegs ? [RawPath(driverSegs)] : [];
case NodeAclScopeKind.RawDevice:
return TryDeviceSegments(scopeId) is { } deviceSegs ? [RawPath(deviceSegs)] : [];
case NodeAclScopeKind.RawTagGroup:
return TryGroupSegments(scopeId) is { } groupSegs ? [RawPath(groupSegs)] : [];
// Cluster never reaches here (the caller short-circuits it), and FolderSegment is the retired
// v2 kind with no v3 entity behind it — deliberately unresolvable. The trie builder now
// short-circuits FolderSegment too, so it reports RetiredScopeKind rather than the
// indistinguishable-from-a-typo UnresolvedScope (Gitea #533); this arm stays as the answer
// for a direct ResolveOne caller.
case NodeAclScopeKind.Cluster:
case NodeAclScopeKind.FolderSegment:
default:
return [];
}
}
private IReadOnlyList<NodeAclPath> ResolveTag(string tagId)
{
var paths = new List<NodeAclPath>();
if (_tags.TryGetValue(tagId, out var tag))
{
var rawPath = _rawPaths.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
if (rawPath is not null)
paths.Add(RawPath(rawPath.Split(RawPaths.Separator)));
}
// Every UNS projection of this raw tag. A tag referenced by three equipments is reachable at four
// NodeIds, and a write through any of them lands on the same driver reference — so a Tag-scoped
// grant has to cover all four or it is bypassable through whichever one it missed.
foreach (var reference in _references)
{
if (!string.Equals(reference.TagId, tagId, StringComparison.OrdinalIgnoreCase)) continue;
if (TryEquipmentSegments(reference.EquipmentId) is not { } equipSegs) continue;
paths.Add(UnsPath([.. equipSegs, reference.EffectiveName]));
}
return paths;
}
private string[]? TryUnsLineSegments(string lineId)
{
if (!_lines.TryGetValue(lineId, out var line)) return null;
if (!_areas.TryGetValue(line.UnsAreaId, out var areaName)) return null;
return [areaName, line.Name];
}
private string[]? TryEquipmentSegments(string equipmentId)
{
if (!_equipment.TryGetValue(equipmentId, out var equip)) return null;
if (TryUnsLineSegments(equip.UnsLineId) is not { } lineSegs) return null;
return [.. lineSegs, equip.Name];
}
private string[]? TryFolderChain(string? leafFolderId)
{
if (leafFolderId is null) return [];
var chain = new List<string>();
var current = leafFolderId;
var guard = 0;
// Depth-bounded like RawPathResolver's own walks: this is a corrupt-parent-cycle guard, not a
// schema limit — raw folder nesting is unbounded by design.
while (current is not null)
{
if (++guard > MaxAncestryDepth) return null;
if (!_folders.TryGetValue(current, out var folder)) return null;
chain.Add(folder.Name);
current = folder.ParentId;
}
chain.Reverse();
return [.. chain];
}
private string[]? TryDriverSegments(string driverInstanceId)
{
if (!_drivers.TryGetValue(driverInstanceId, out var driver)) return null;
if (TryFolderChain(driver.RawFolderId) is not { } folderSegs) return null;
return [.. folderSegs, driver.Name];
}
private string[]? TryDeviceSegments(string deviceId)
{
if (!_devices.TryGetValue(deviceId, out var device)) return null;
if (TryDriverSegments(device.DriverInstanceId) is not { } driverSegs) return null;
return [.. driverSegs, device.Name];
}
private string[]? TryGroupSegments(string tagGroupId)
{
if (!_groups.TryGetValue(tagGroupId, out var group)) return null;
if (TryDeviceSegments(group.DeviceId) is not { } deviceSegs) return null;
var chain = new List<string>();
string? current = tagGroupId;
var guard = 0;
while (current is not null)
{
if (++guard > MaxAncestryDepth) return null;
if (!_groups.TryGetValue(current, out var node)) return null;
chain.Add(node.Name);
current = node.ParentId;
}
chain.Reverse();
return [.. deviceSegs, .. chain];
}
/// <summary>Re-keys an id map under the identifier comparer — see the rule on <see cref="AclScopeRef"/>.</summary>
private static Dictionary<string, TValue> ById<TValue>(IReadOnlyDictionary<string, TValue> source)
{
var copy = new Dictionary<string, TValue>(source.Count, StringComparer.OrdinalIgnoreCase);
// Last-wins on a case-collision, matching the GroupBy/First tolerance the deploy gate applies to
// duplicate ids: a duplicate is another rule's business, and throwing here would fault the whole
// decode over one malformed row.
foreach (var (key, value) in source) copy[key] = value;
return copy;
}
// No params-array overloads here: string[] binds to both params and IEnumerable, and the resulting
// ambiguity would be resolved silently by overload rules rather than by intent.
private static NodeAclPath RawPath(IEnumerable<string> segments) =>
new([NodeAclRealms.Raw, .. segments]);
private static NodeAclPath UnsPath(IEnumerable<string> segments) =>
new([NodeAclRealms.Uns, .. segments]);
private const int MaxAncestryDepth = 256;
}
@@ -22,7 +22,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
public sealed class DataPlaneAuthoritySnapshot
{
/// <summary>The state of a node that has not yet applied an artifact. Authenticates nothing.</summary>
public static readonly DataPlaneAuthoritySnapshot Empty = Create(null, string.Empty, 0, [], []);
public static readonly DataPlaneAuthoritySnapshot Empty =
Create(null, string.Empty, 0, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
private DataPlaneAuthoritySnapshot(
string? clusterId,
@@ -30,6 +31,7 @@ public sealed class DataPlaneAuthoritySnapshot
long generationId,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyDictionary<string, ApiKey> keysById,
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal)
{
@@ -38,6 +40,7 @@ public sealed class DataPlaneAuthoritySnapshot
GenerationId = generationId;
Keys = keys;
Grants = grants;
ScopePaths = scopePaths;
_keysById = keysById;
_grantsByPrincipal = grantsByPrincipal;
}
@@ -66,6 +69,13 @@ public sealed class DataPlaneAuthoritySnapshot
/// <summary>Every ACL grant scoped to this node's cluster.</summary>
public IReadOnlyList<NodeAcl> Grants { get; }
/// <summary>
/// Where each grant's scope sits in the address space, resolved from the same artifact the grants
/// came from. Feeds <see cref="PermissionTrieBuilder.Build"/>; a scope absent here has no
/// deployed node and its grant is dropped rather than planted somewhere unreachable.
/// </summary>
public IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths { get; }
/// <summary>True once a real artifact has been applied.</summary>
public bool HasArtifact => GenerationId > 0;
@@ -101,17 +111,20 @@ public sealed class DataPlaneAuthoritySnapshot
/// <param name="generationId">The node-local generation stamp.</param>
/// <param name="keys">The API keys carried by the artifact.</param>
/// <param name="grants">The ACL grants for this node's cluster.</param>
/// <param name="scopePaths">Where each grant's scope sits in the address space.</param>
/// <returns>The built snapshot.</returns>
public static DataPlaneAuthoritySnapshot Create(
string? clusterId,
string revisionHash,
long generationId,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants)
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths)
{
ArgumentNullException.ThrowIfNull(revisionHash);
ArgumentNullException.ThrowIfNull(keys);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(scopePaths);
// OrdinalIgnoreCase throughout: key ids are minted as lowercase hex, but a client hand-copying a
// key out of the AdminUI can upper-case it, and the principal comparison must agree with the
@@ -140,7 +153,8 @@ public sealed class DataPlaneAuthoritySnapshot
kv => (IReadOnlyList<NodeAcl>)kv.Value,
StringComparer.OrdinalIgnoreCase);
return new DataPlaneAuthoritySnapshot(clusterId, revisionHash, generationId, keys, grants, byId, frozen);
return new DataPlaneAuthoritySnapshot(
clusterId, revisionHash, generationId, keys, grants, scopePaths, byId, frozen);
}
}
@@ -179,12 +193,14 @@ public interface IDataPlaneAuthoritySink
/// <param name="revisionHash">SHA-256 of the artifact bytes.</param>
/// <param name="keys">The API keys the artifact carries.</param>
/// <param name="grants">The ACL grants for this node's cluster.</param>
/// <param name="scopePaths">Where each grant's scope sits in the address space.</param>
/// <returns>The installed snapshot.</returns>
DataPlaneAuthoritySnapshot Apply(
string? clusterId,
string revisionHash,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants);
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths);
}
/// <summary>
@@ -204,7 +220,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
string? clusterId,
string revisionHash,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants)
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths)
{
ArgumentNullException.ThrowIfNull(revisionHash);
@@ -227,7 +244,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
? previous.GenerationId
: previous.GenerationId + 1;
var next = DataPlaneAuthoritySnapshot.Create(clusterId, revisionHash, generation, keys, grants);
var next = DataPlaneAuthoritySnapshot.Create(
clusterId, revisionHash, generation, keys, grants, scopePaths);
Volatile.Write(ref _current, next);
return next;
}
@@ -1,47 +1,146 @@
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// Address of a node in the 6-level scope hierarchy the Phase 6.2 evaluator walks.
/// Assembled by the dispatch layer from the node's namespace + UNS path + tag; passed
/// to <see cref="IPermissionEvaluator"/> which walks the matching trie path.
/// Address of a node in the ACL scope hierarchy the evaluator walks: a cluster, a realm, and the
/// node's path within that realm. Assembled by the dispatch layer from the node's NodeId; passed to
/// <see cref="IPermissionEvaluator"/>, which walks the matching trie path.
/// </summary>
/// <remarks>
/// <para>Per the Phase 6.2 Stream B plan the hierarchy is
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> for all
/// (Equipment-kind) namespaces. Galaxy is a standard Equipment-kind driver, so Galaxy
/// points are ordinary equipment tags that resolve through this same walk.</para>
///
/// <para>Unset mid-path levels (e.g. a Cluster-scoped request with no UnsArea) leave
/// the corresponding id <c>null</c>. The evaluator walks as far as the scope goes +
/// stops at the first null.</para>
/// <para>
/// The hierarchy is <c>Cluster → Realm → …node path…</c>. The node path is <b>variable length</b>,
/// which is the whole reason this type is no longer a fixed set of level properties (#520 stage 3):
/// </para>
/// <list type="bullet">
/// <item>
/// <b>UNS</b> paths are always <c>Area / Line / Equipment [/ EffectiveName]</c> — fixed depth,
/// because equipment is a tree leaf.
/// </item>
/// <item>
/// <b>Raw</b> paths are <c>folder… / Driver / Device / group… / Tag</c>, and BOTH the folder
/// chain and the tag-group chain nest arbitrarily deep (<c>RawFolder.ParentRawFolderId</c>,
/// <c>TagGroup.ParentTagGroupId</c>). There is no schema depth cap — the 256 in
/// <c>RawPathResolver</c> is a corrupt-cycle guard, not a limit. A fixed-arity scope record
/// therefore cannot address a real raw tree at all; the previous six-property shape silently
/// capped it at five levels and mislabelled every one of them.
/// </item>
/// </list>
/// <para>
/// The realm segment is NOT carried here. It is derived from <see cref="Kind"/> by
/// <see cref="NodeAclRealms.SegmentFor(NodeHierarchyKind)"/>, which the scope-path producer also uses — so the two
/// sides cannot disagree about its spelling. That matters more than it looks: the realm is a hard
/// gate at level 1 of the walk, so a mismatch would make every sub-cluster grant in the system
/// unreachable while cluster-scoped grants kept working, which reads as "ACLs work" right up until
/// someone tries to narrow one.
/// </para>
/// </remarks>
public sealed record NodeScope
{
/// <summary>Cluster the node belongs to. Required.</summary>
public required string ClusterId { get; init; }
/// <summary>Namespace within the cluster. Null is not allowed for a request against a real node.</summary>
public string? NamespaceId { get; init; }
/// <summary>UNS area (e.g. "warsaw-west") below the namespace.</summary>
public string? UnsAreaId { get; init; }
/// <summary>UNS line below the area.</summary>
public string? UnsLineId { get; init; }
/// <summary>Equipment row below the line.</summary>
public string? EquipmentId { get; init; }
/// <summary>Target tag id when the scope addresses a specific tag; null for equipment-level scopes.</summary>
public string? TagId { get; init; }
/// <summary>Which hierarchy applies. Currently only Equipment-kind (UNS) exists.</summary>
/// <summary>Which realm — and therefore which hierarchy shape — the node lives in.</summary>
public required NodeHierarchyKind Kind { get; init; }
/// <summary>
/// The node's path within its realm, root-first, excluding the realm segment itself. Empty
/// addresses the realm as a whole. Segment comparison is case-sensitive ordinal, matching
/// <c>RawPaths.Comparer</c> and OPC UA NodeId identity.
/// </summary>
public required IReadOnlyList<string> Segments { get; init; }
}
/// <summary>Selector for the scope-hierarchy shape.</summary>
/// <summary>Selector for the scope-hierarchy shape — one member per OPC UA namespace (realm).</summary>
public enum NodeHierarchyKind
{
/// <summary><c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> — UNS / Equipment kind.</summary>
Equipment,
/// <summary><c>Area / Line / Equipment [/ EffectiveName]</c> — the UNS realm.</summary>
Uns,
/// <summary><c>folder… / Driver / Device / group… / Tag</c> — the raw device-tree realm. (Appended, #520 stage 3.)</summary>
Raw,
}
/// <summary>
/// The single authority for the realm segment that sits at level 1 of every permission trie, directly
/// under the cluster root.
/// </summary>
/// <remarks>
/// <para>
/// This exists because v3 <b>has no <c>Namespace</c> entity</b>. The snapshot was retired when the
/// address space went dual-namespace ("the two OPC UA namespaces are implicit"), which left the
/// trie's level-1 gate with no configured key source at all — the pre-stage-3 walker read a
/// <c>NamespaceId</c> that nothing in the system ever produced, so the level could only ever fail
/// to match. Deriving the segment from the realm restores the level as something real without
/// reintroducing an entity.
/// </para>
/// <para>
/// The short tokens, rather than the namespace URIs, are deliberate: they are what an operator
/// types into a <see cref="Configuration.Enums.NodeAclScopeKind.Namespace"/> row's <c>ScopeId</c>,
/// and they read clearly in a diagnostic. They cannot collide with a user-authored segment,
/// because only a realm token is ever placed at level 1 — real names start at level 2.
/// </para>
/// </remarks>
public static class NodeAclRealms
{
/// <summary>Realm segment for the raw device tree (<c>https://zb.com/otopcua/raw</c>).</summary>
public const string Raw = "raw";
/// <summary>Realm segment for the UNS tree (<c>https://zb.com/otopcua/uns</c>).</summary>
public const string Uns = "uns";
/// <summary>The realm segment for a hierarchy kind.</summary>
/// <param name="kind">The hierarchy kind.</param>
/// <returns>The level-1 trie segment.</returns>
/// <exception cref="ArgumentOutOfRangeException">The kind is not a defined member.</exception>
public static string SegmentFor(NodeHierarchyKind kind) => kind switch
{
NodeHierarchyKind.Uns => Uns,
NodeHierarchyKind.Raw => Raw,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unhandled hierarchy kind."),
};
/// <summary>The realm segment for an address-space realm.</summary>
/// <param name="realm">The address-space realm carried at the OPC UA sinks.</param>
/// <returns>The level-1 trie segment.</returns>
/// <exception cref="ArgumentOutOfRangeException">The realm is not a defined member.</exception>
public static string SegmentFor(AddressSpaceRealm realm) => realm switch
{
AddressSpaceRealm.Uns => Uns,
AddressSpaceRealm.Raw => Raw,
_ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unhandled address-space realm."),
};
/// <summary>
/// True when <paramref name="segment"/> is exactly a canonical realm segment. Used by the trie
/// builder to reject a scope path that does not start at a realm — such a path addresses a node
/// the walk cannot reach, so the grant would look planted and never match.
/// </summary>
/// <param name="segment">The candidate segment.</param>
/// <returns><see langword="true"/> when it is a realm segment.</returns>
public static bool IsRealmSegment(string? segment) =>
string.Equals(segment, Raw, StringComparison.Ordinal)
|| string.Equals(segment, Uns, StringComparison.Ordinal);
/// <summary>
/// Canonicalizes an operator-supplied realm token from a <c>Namespace</c>-scoped row's
/// <c>ScopeId</c>. Accepts the short token or the full namespace URI, in any case.
/// </summary>
/// <param name="scopeId">The authored scope id.</param>
/// <returns>The canonical realm segment, or <see langword="null"/> when it names no realm.</returns>
public static string? TryCanonicalize(string? scopeId)
{
if (string.IsNullOrWhiteSpace(scopeId)) return null;
var trimmed = scopeId.Trim();
if (string.Equals(trimmed, Raw, StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, V3NodeIds.RawNamespaceUri, StringComparison.OrdinalIgnoreCase))
return Raw;
if (string.Equals(trimmed, Uns, StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, V3NodeIds.UnsNamespaceUri, StringComparison.OrdinalIgnoreCase))
return Uns;
return null;
}
}
@@ -3,17 +3,16 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// In-memory permission trie for one <c>(ClusterId, GenerationId)</c>. Walk from the cluster
/// root down through namespace → UNS levels → tag, OR-ing the
/// In-memory permission trie for one <c>(ClusterId, GenerationId)</c>. Walk from the cluster root
/// down through the realm and then each segment of the node's path, OR-ing the
/// <see cref="TrieGrant.PermissionFlags"/> granted at each visited level for each of the session's
/// principals. The accumulated bitmask is compared to the permission required by the
/// requested <see cref="Abstractions.OpcUaOperation"/>.
/// principals. The accumulated bitmask is compared to the permission required by the requested
/// <see cref="Abstractions.OpcUaOperation"/>.
/// </summary>
/// <remarks>
/// The walk is pure union (additive grants; no explicit Deny in v2.0):
/// encountering a grant at any level contributes its flags, never revokes them. A grant at
/// the Cluster root therefore cascades to every tag below it; a grant at a deep equipment
/// leaf is visible only on that equipment subtree.
/// The walk is pure union (additive grants; no explicit Deny in v2.0): encountering a grant at any
/// level contributes its flags, never revokes them. A grant at the cluster root therefore cascades to
/// every node below it; a grant at a deep leaf is visible only on that subtree.
/// </remarks>
public sealed class PermissionTrie
{
@@ -27,9 +26,9 @@ public sealed class PermissionTrie
public PermissionTrieNode Root { get; init; } = new();
/// <summary>
/// Walk the trie collecting grants that apply to <paramref name="scope"/> for any of the
/// session's <paramref name="principals"/>. Returns the matched-grant list; the caller
/// OR-s the flag bits to decide whether the requested permission is carried.
/// Walk the trie collecting grants that apply to <paramref name="scope"/> for any of the session's
/// <paramref name="principals"/>. Returns the matched-grant list; the caller OR-s the flag bits to
/// decide whether the requested permission is carried.
/// </summary>
/// <param name="scope">The node scope to match permissions for.</param>
/// <param name="principals">The principal names carried by the session (API-key principals, or the reserved "anonymous").</param>
@@ -44,47 +43,42 @@ public sealed class PermissionTrie
var matches = new List<MatchedGrant>();
// Level 0 — cluster-scoped grants.
CollectAtLevel(Root, NodeAclScopeKind.Cluster, groups, matches);
// Level 0 — cluster-scoped grants apply to every node in the cluster.
CollectAtLevel(Root, groups, matches);
// Level 1 — namespace.
if (scope.NamespaceId is null) return matches;
if (!Root.Children.TryGetValue(scope.NamespaceId, out var ns)) return matches;
CollectAtLevel(ns, NodeAclScopeKind.Namespace, groups, matches);
// Level 1 — the realm. Derived from the scope's kind rather than carried on it, so this can
// never disagree with the spelling the scope-path producer used. SegmentFor throws on an
// undefined kind, which is deliberate: a new realm that nobody taught this walk about must fail
// loudly rather than walk some other realm's subtree and return a confidently wrong verdict.
var realm = NodeAclRealms.SegmentFor(scope.Kind);
if (!Root.Children.TryGetValue(realm, out var node)) return matches;
CollectAtLevel(node, groups, matches);
// Below the namespace every kind walks the Equipment hierarchy — Galaxy is a standard
// Equipment-kind driver, so Galaxy points are ordinary equipment tags keyed by
// EquipmentId/path and resolve through this same walk.
WalkEquipment(ns, scope, groups, matches);
// Levels 2..n — the node's path within the realm, however deep it happens to be. There is no
// per-kind ladder here on purpose: the UNS path (Area/Line/Equipment) and a raw path
// (folder…/Driver/Device/group…/Tag) are both just ordered segments, and raw nesting is
// unbounded, so any fixed ladder would silently truncate deep raw trees.
foreach (var segment in scope.Segments)
{
if (segment is null || !node.Children.TryGetValue(segment, out var next)) return matches;
node = next;
CollectAtLevel(node, groups, matches);
}
return matches;
}
private static void WalkEquipment(PermissionTrieNode ns, NodeScope scope, HashSet<string> groups, List<MatchedGrant> matches)
{
if (scope.UnsAreaId is null) return;
if (!ns.Children.TryGetValue(scope.UnsAreaId, out var area)) return;
CollectAtLevel(area, NodeAclScopeKind.UnsArea, groups, matches);
if (scope.UnsLineId is null) return;
if (!area.Children.TryGetValue(scope.UnsLineId, out var line)) return;
CollectAtLevel(line, NodeAclScopeKind.UnsLine, groups, matches);
if (scope.EquipmentId is null) return;
if (!line.Children.TryGetValue(scope.EquipmentId, out var eq)) return;
CollectAtLevel(eq, NodeAclScopeKind.Equipment, groups, matches);
if (scope.TagId is null) return;
if (!eq.Children.TryGetValue(scope.TagId, out var tag)) return;
CollectAtLevel(tag, NodeAclScopeKind.Tag, groups, matches);
}
private static void CollectAtLevel(PermissionTrieNode node, NodeAclScopeKind level, HashSet<string> groups, List<MatchedGrant> matches)
private static void CollectAtLevel(PermissionTrieNode node, HashSet<string> groups, List<MatchedGrant> matches)
{
foreach (var grant in node.Grants)
{
// Provenance is the granting ROW's own scope kind, recorded when the trie was built — not the
// structural depth the grant happens to sit at. Depth-derived provenance was wrong in two
// directions: it could not name a raw level at all (the depth of "Device" varies with folder
// nesting), and where a producer emitted a path whose length disagreed with the row's kind it
// reported a level the operator never authored.
if (groups.Contains(grant.Principal))
matches.Add(new MatchedGrant(grant.Principal, level, grant.PermissionFlags));
matches.Add(new MatchedGrant(grant.Principal, grant.ScopeKind, grant.PermissionFlags));
}
}
}
@@ -96,12 +90,21 @@ public sealed class PermissionTrieNode
public List<TrieGrant> Grants { get; } = [];
/// <summary>
/// Children keyed by the next level's id — namespace id under cluster; UnsAreaId or
/// folder-segment name under namespace; etc. Comparer is OrdinalIgnoreCase so the walk
/// tolerates case drift between the NodeAcl row and the requested scope.
/// Children keyed by the next level's segment — the realm token under the root, then entity names.
/// </summary>
public Dictionary<string, PermissionTrieNode> Children { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <remarks>
/// ⚠️ The comparer is <b>case-sensitive ordinal</b>, matching <c>RawPaths.Comparer</c> and OPC UA
/// NodeId identity. It was <c>OrdinalIgnoreCase</c> before #520 stage 3, which was a silent
/// widening: raw sibling names are unique under an ordinal comparer, so <c>Speed</c> and
/// <c>speed</c> can both exist under one device, and a grant on either would have matched both.
/// Case-sensitive matching is also the fail-closed direction — a case-drifted ACL row now grants
/// nothing instead of granting something adjacent.
/// </remarks>
public Dictionary<string, PermissionTrieNode> Children { get; } = new(StringComparer.Ordinal);
}
/// <summary>Projection of a <see cref="Configuration.Entities.NodeAcl"/> row into the trie.</summary>
public sealed record TrieGrant(string Principal, NodePermissions PermissionFlags);
/// <param name="Principal">The principal the row grants to.</param>
/// <param name="ScopeKind">The row's authored scope kind, carried for decision provenance.</param>
/// <param name="PermissionFlags">The permission bits the row grants.</param>
public sealed record TrieGrant(string Principal, NodeAclScopeKind ScopeKind, NodePermissions PermissionFlags);
@@ -4,107 +4,128 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// Builds a <see cref="PermissionTrie"/> from a set of <see cref="NodeAcl"/> rows anchored
/// in one generation. The trie is keyed on the rows' scope hierarchy — rows with
/// <see cref="NodeAclScopeKind.Cluster"/> land at the trie root, rows with
/// <see cref="NodeAclScopeKind.Tag"/> land at a leaf, etc.
/// Builds a <see cref="PermissionTrie"/> from a set of <see cref="NodeAcl"/> rows anchored in one
/// generation. Rows with <see cref="NodeAclScopeKind.Cluster"/> land at the trie root; every other
/// row is placed at the trie node(s) its <c>ScopeId</c> resolves to.
/// </summary>
/// <remarks>
/// <para>Intended to be called by <see cref="PermissionTrieCache"/> once per published
/// generation; the resulting trie is immutable for the life of the cache entry. Idempotent —
/// two builds from the same rows produce equal tries (grant lists may be in insertion order;
/// evaluators don't depend on order).</para>
///
/// <para>The builder deliberately does not know about the node-row metadata the trie path
/// will be walked with. The caller assembles <see cref="NodeScope"/> values from the live
/// config (UnsArea parent of UnsLine, etc.); this class only honors the <c>ScopeId</c>
/// each row carries.</para>
/// <para>
/// Intended to be called once per published generation; the resulting trie is immutable for the
/// life of the cache entry. Idempotent — two builds from the same rows produce equal tries.
/// </para>
/// <para>
/// ⚠️ <b>There is no longer a "deterministic test mode".</b> Before #520 stage 3 a null
/// <c>scopePaths</c> made sub-cluster rows fall back to a direct child of the trie
/// root keyed on <c>ScopeId</c>. That position is unreachable by construction — level 1 of the walk
/// is the realm — so the fallback produced grants that looked placed and could never match, and
/// every test that used it proved nothing about production placement. A row that cannot be
/// resolved is now <b>dropped with a diagnostic</b>, which is the fail-closed direction and is
/// honest about what happened.
/// </para>
/// </remarks>
public static class PermissionTrieBuilder
{
/// <summary>
/// Build a trie for one cluster/generation from the supplied rows. The caller is
/// responsible for pre-filtering rows to the target generation + cluster.
/// Build a trie for one cluster/generation from the supplied rows. The caller is responsible for
/// pre-filtering rows to the target generation + cluster.
/// </summary>
/// <param name="clusterId">Cluster the trie is being built for; rows for other clusters are skipped.</param>
/// <param name="generationId">Config-generation the rows belong to; stamped on the returned trie.</param>
/// <param name="rows">ACL rows for this cluster + generation.</param>
/// <param name="scopePaths">
/// Optional <c>ScopeId</c> → multi-level trie-path lookup. When supplied, sub-cluster rows
/// descend to their structurally-correct trie node. When null, sub-cluster rows fall back
/// to a direct child of the trie root keyed on <c>ScopeId</c> — deterministic-test mode.
/// <c>(ScopeKind, ScopeId)</c> → the trie path(s) that scope resolves to, as produced by
/// <see cref="AclScopePathResolver" />. A scope resolves to <b>several</b> paths when one entity is
/// reachable at several places in the address space — a raw tag projected into N equipments by
/// <c>UnsTagReference</c> rows has one raw path plus N UNS paths, and a grant on it must cover all
/// of them or it would be bypassable through the NodeId it missed.
/// </param>
/// <param name="diagnostic">
/// Optional callback invoked when a sub-cluster row's <c>ScopeId</c> cannot be located
/// in <paramref name="scopePaths"/>. Production callers should wire a logger here so
/// orphaned grants surface — silently dropping them under the wrong trie level was a
/// production hazard. The callback fires only when <paramref name="scopePaths"/>
/// is non-null (a null lookup is the explicit deterministic-test fallback mode).
/// Optional callback invoked for every row that could not be placed. Production callers should wire
/// a logger here — a dropped grant is invisible otherwise.
/// </param>
/// <returns>The built <see cref="PermissionTrie"/> for the cluster + generation.</returns>
public static PermissionTrie Build(
string clusterId,
long generationId,
IReadOnlyList<NodeAcl> rows,
IReadOnlyDictionary<string, NodeAclPath>? scopePaths = null,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
Action<PermissionTrieBuildDiagnostic>? diagnostic = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
ArgumentNullException.ThrowIfNull(rows);
ArgumentNullException.ThrowIfNull(scopePaths);
var trie = new PermissionTrie { ClusterId = clusterId, GenerationId = generationId };
foreach (var row in rows)
{
if (!string.Equals(row.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
var grant = new TrieGrant(row.Principal, row.PermissionFlags);
var node = row.ScopeKind switch
var grant = new TrieGrant(row.Principal, row.ScopeKind, row.PermissionFlags);
if (row.ScopeKind == NodeAclScopeKind.Cluster)
{
NodeAclScopeKind.Cluster => trie.Root,
_ => Descend(trie.Root, row, scopePaths, diagnostic),
};
trie.Root.Grants.Add(grant);
continue;
}
if (node is not null)
// Ahead of the ScopeId checks, mirroring the deploy gate's ordering (DraftValidator's
// NodeAclScopeKindRetired): the retirement is the actionable fact, and a row carrying this
// kind can never resolve whatever its ScopeId, so reporting it as "unresolved" would send an
// operator hunting for a typo in an id that was never the problem.
if (row.ScopeKind == NodeAclScopeKind.FolderSegment)
{
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
row.NodeAclId, row.ScopeKind, row.ScopeId,
PermissionTrieBuildDiagnosticReason.RetiredScopeKind));
continue;
}
// A sub-cluster row with no ScopeId names nothing. It used to vanish here with no signal at
// all — unlike an unresolvable ScopeId, which at least emitted a diagnostic. Both are
// authoring mistakes and both now say so.
if (string.IsNullOrEmpty(row.ScopeId))
{
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
row.NodeAclId, row.ScopeKind, ScopeId: null,
PermissionTrieBuildDiagnosticReason.MissingScopeId));
continue;
}
if (!scopePaths.TryGetValue(new AclScopeRef(row.ScopeKind, row.ScopeId), out var paths)
|| paths.Count == 0)
{
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
row.NodeAclId, row.ScopeKind, row.ScopeId,
PermissionTrieBuildDiagnosticReason.UnresolvedScope));
continue;
}
foreach (var path in paths)
{
// Guard the producer's output contract. Level 1 of the walk is always a realm token, so a
// path that is empty or does not start with one addresses a node the walker can never
// reach — the grant would look planted and could never match. That is the same silent
// failure the deleted root-child fallback used to produce; without this check it simply
// moves into the resolver, where nothing else cross-checks it.
if (path.Segments.Count == 0 || !NodeAclRealms.IsRealmSegment(path.Segments[0]))
{
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
row.NodeAclId, row.ScopeKind, row.ScopeId,
PermissionTrieBuildDiagnosticReason.InvalidScopePath));
continue;
}
var node = trie.Root;
foreach (var segment in path.Segments)
node = EnsureChild(node, segment);
node.Grants.Add(grant);
}
}
return trie;
}
private static PermissionTrieNode? Descend(
PermissionTrieNode root,
NodeAcl row,
IReadOnlyDictionary<string, NodeAclPath>? scopePaths,
Action<PermissionTrieBuildDiagnostic>? diagnostic)
{
if (string.IsNullOrEmpty(row.ScopeId)) return null;
// For sub-cluster scopes the caller supplies a path lookup so we know the containing
// namespace / UnsArea / UnsLine ids. Without a path lookup we fall back to putting the
// row directly under the root using its ScopeId — works for deterministic tests, not
// for production where the hierarchy must be honored. If a scopePaths lookup IS
// provided but is missing the row's ScopeId, surface a diagnostic so the caller can
// log the orphan instead of silently dropping the grant under an unreachable node.
if (scopePaths is null || !scopePaths.TryGetValue(row.ScopeId, out var path))
{
if (scopePaths is not null)
{
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
NodeAclId: row.NodeAclId,
ScopeKind: row.ScopeKind,
ScopeId: row.ScopeId,
Reason: PermissionTrieBuildDiagnosticReason.MissingScopePath));
}
return EnsureChild(root, row.ScopeId);
}
var node = root;
foreach (var segment in path.Segments)
node = EnsureChild(node, segment);
return node;
}
private static PermissionTrieNode EnsureChild(PermissionTrieNode parent, string key)
{
if (!parent.Children.TryGetValue(key, out var child))
@@ -117,37 +138,51 @@ public static class PermissionTrieBuilder
}
/// <summary>
/// Ordered list of trie-path segments from root to the target node. Supplied to
/// <see cref="PermissionTrieBuilder.Build"/> so the builder knows where a
/// <see cref="NodeAclScopeKind.UnsLine"/>-scoped row sits in the hierarchy.
/// Ordered list of trie-path segments from the cluster root to the target node, <b>including</b> the
/// realm segment at index 0 (see <see cref="NodeAclRealms"/>).
/// </summary>
/// <param name="Segments">
/// Namespace id, then UnsAreaId / UnsLineId / EquipmentId / TagId as applicable.
/// </param>
/// <param name="Segments">Realm token, then the entity names that lead to the scoped node.</param>
public sealed record NodeAclPath(IReadOnlyList<string> Segments);
/// <summary>
/// Diagnostic emitted by <see cref="PermissionTrieBuilder.Build"/> when a row could not be
/// placed at its structurally-correct trie node. Production callers should log these so
/// orphaned grants surface instead of being silently dropped under an unreachable node.
/// Diagnostic emitted by <see cref="PermissionTrieBuilder.Build"/> for a row that could not be placed.
/// Production callers should log these so dropped grants surface to an operator.
/// </summary>
/// <param name="NodeAclId">The offending row's logical id.</param>
/// <param name="ScopeKind">The row's <see cref="NodeAclScopeKind"/>.</param>
/// <param name="ScopeId">The row's <c>ScopeId</c> that could not be located.</param>
/// <param name="ScopeId">The row's <c>ScopeId</c>, or <see langword="null"/> when it had none.</param>
/// <param name="Reason">Why the diagnostic fired.</param>
public sealed record PermissionTrieBuildDiagnostic(
string NodeAclId,
NodeAclScopeKind ScopeKind,
string ScopeId,
string? ScopeId,
PermissionTrieBuildDiagnosticReason Reason);
/// <summary>Reasons <see cref="PermissionTrieBuildDiagnostic"/> can be emitted.</summary>
/// <summary>Reasons <see cref="PermissionTrieBuildDiagnostic"/> can be emitted. The grant is dropped in every case.</summary>
public enum PermissionTrieBuildDiagnosticReason
{
/// <summary>
/// The row's <c>ScopeId</c> was not present in the supplied <c>scopePaths</c> lookup.
/// The grant is placed as a direct child of the trie root keyed on <c>ScopeId</c> — a
/// position the production trie walker cannot reach for multi-level scopes.
/// The row's <c>ScopeId</c> did not resolve to any node in the deployed address space — a dangling
/// id, or an entity someone deleted after the grant was authored. The remedy is to correct the id
/// or delete the grant.
/// </summary>
MissingScopePath,
UnresolvedScope,
/// <summary>The row is narrower than <see cref="NodeAclScopeKind.Cluster"/> but carries no <c>ScopeId</c>.</summary>
MissingScopeId,
/// <summary>
/// The row's <see cref="NodeAclScopeKind"/> is retired and has no v3 entity behind it
/// (<see cref="NodeAclScopeKind.FolderSegment"/>), so it can never resolve whatever its
/// <c>ScopeId</c>. Split out of <see cref="UnresolvedScope"/> by Gitea #533: the two have
/// different remedies — re-scope the grant to <see cref="NodeAclScopeKind.RawFolder"/> versus
/// fix an id — and conflating them told an operator with a legacy row to go looking for a typo.
/// </summary>
RetiredScopeKind,
/// <summary>
/// A resolved path was empty or did not begin with a realm segment, so it addresses a trie node
/// the walk cannot reach. This is a bug in the scope-path producer, not an authoring mistake.
/// </summary>
InvalidScopePath,
}
@@ -122,4 +122,19 @@ public sealed record AbCipTagCreateParams(
bool AllowPacking = true,
int ConnectionSize = 4002,
int ElementCount = 1,
bool IsArray = false);
bool IsArray = false)
{
/// <summary>
/// The value for libplctag's <c>gateway</c> attribute: <c>host:port</c>, or bare <c>host</c> when
/// no usable port was configured (libplctag then applies its own 44818 default).
/// </summary>
/// <remarks>
/// ⚠️ <b><see cref="Port"/> used to be carried here and never consumed</b> (Gitea #534, found in
/// the AbLegacy driver, which had the identical defect). The runtime passed the bare host to
/// libplctag, which silently substitutes 44818 — so an authored port did not merely fail to apply,
/// it redirected the connection to whatever answers on the default port, which on a plant network
/// is another PLC. This driver's own tests never caught it because its fixture sits on 44818, where
/// the bug is invisible by construction.
/// </remarks>
public string LibplctagGateway => Port > 0 ? $"{Gateway}:{Port}" : Gateway;
}
@@ -19,7 +19,9 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
_tag = new Tag
{
Gateway = p.Gateway,
// host:port — see AbLegacyTagCreateParams/AbCipTagCreateParams.LibplctagGateway.
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default (#534).
Gateway = p.LibplctagGateway,
Path = p.CipPath,
PlcType = MapPlcType(p.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
@@ -79,4 +79,19 @@ public sealed record AbLegacyTagCreateParams(
string LibplctagPlcAttribute,
string TagName,
TimeSpan Timeout,
int ElementCount = 1);
int ElementCount = 1)
{
/// <summary>
/// The value for libplctag's <c>gateway</c> attribute: <c>host:port</c>, or bare <c>host</c> when
/// no usable port was configured (libplctag then applies its own 44818 default).
/// </summary>
/// <remarks>
/// ⚠️ <b><see cref="Port"/> used to be carried here and never consumed</b> (Gitea #534). The runtime
/// passed the bare host to libplctag, which silently substitutes 44818 — so an authored port was
/// not merely ignored, it redirected the connection to whatever answers on the default port. On the
/// test rig that was a different simulator of a different PLC family, which is how the AbLegacy
/// integration suite failed for years while looking like a fixture problem. On a plant network it
/// would be another PLC.
/// </remarks>
public string LibplctagGateway => Port > 0 ? $"{Gateway}:{Port}" : Gateway;
}
@@ -18,7 +18,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
_tag = new Tag
{
Gateway = p.Gateway,
// host:port — see AbLegacyTagCreateParams/AbCipTagCreateParams.LibplctagGateway.
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default (#534).
Gateway = p.LibplctagGateway,
Path = p.CipPath,
PlcType = MapPlcType(p.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer
@@ -29,15 +29,28 @@ else if (!IsNew && _existing is null)
else
{
<div class="alert alert-warning" role="alert">
<strong>This grant will not be enforced.</strong>
It is saved and shipped in every deployment artifact, but the OPC UA server never evaluates
ACL rows — the permission evaluator has no production call site. Nothing you set below will
change what a client can read, write, browse or acknowledge.
<strong>Only <span class="mono">Cluster</span> scope is enforced today.</strong>
Saved with scope kind <span class="mono">Cluster</span>, this grant confers a server-wide role
on its principal: <span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
(acknowledge / confirm / shelve). No other permission bit is evaluated yet.
<br />
Real access control today is fleet-wide LDAP-group → role mapping
(<span class="mono">Security:Ldap:GroupToRole</span>):
<span class="mono">WriteOperate</span> to write any tag,
<span class="mono">AlarmAck</span> to acknowledge any alarm. Reads are ungated.
Saved with any narrower scope (<span class="mono">Namespace</span>,
<span class="mono">UnsArea</span>, <span class="mono">UnsLine</span>,
<span class="mono">Equipment</span>, <span class="mono">Tag</span>,
<span class="mono">Raw*</span>) it is stored and shipped in every deployment artifact but
<strong>confers nothing</strong> until per-node enforcement lands. That under-granting is
deliberate: honouring its flags while its <em>scope</em> cannot be honoured would turn
"WriteOperate on one equipment" into server-wide write authority.
<br />
Read, Browse, Subscribe and HistoryRead are <strong>not</strong> gated for any admitted
session, including anonymous.
<br />
<strong>Principal</strong> is an API-key principal — or the reserved
<span class="mono">anonymous</span> principal — not an LDAP group: a client presents an OPC UA
UserName token with username <span class="mono">apikey</span> and password
<span class="mono">otua_&lt;keyId&gt;_&lt;secret&gt;</span>. Mint keys on the
<span class="mono">/apikeys</span> page; LDAP now covers AdminUI login only.
See <span class="mono">docs/security.md</span>.
</div>
@@ -54,7 +67,7 @@ else
<div class="col-md-6 mb-3">
<label class="form-label" for="grp">Principal</label>
<InputText id="grp" @bind-Value="_form.Principal" class="form-control form-control-sm mono"
placeholder="cn=Operators,ou=FleetAdmin,dc=zb,dc=local" />
placeholder="scadabridge-dcl" />
</div>
</div>
<div class="row">
@@ -66,14 +79,34 @@ else
<option value="@NodeAclScopeKind.UnsArea">UnsArea</option>
<option value="@NodeAclScopeKind.UnsLine">UnsLine</option>
<option value="@NodeAclScopeKind.Equipment">Equipment</option>
<option value="@NodeAclScopeKind.FolderSegment">FolderSegment</option>
<option value="@NodeAclScopeKind.FolderSegment">FolderSegment (retired — do not author)</option>
<option value="@NodeAclScopeKind.Tag">Tag</option>
<option value="@NodeAclScopeKind.RawFolder">RawFolder</option>
<option value="@NodeAclScopeKind.RawDriver">RawDriver</option>
<option value="@NodeAclScopeKind.RawDevice">RawDevice</option>
<option value="@NodeAclScopeKind.RawTagGroup">RawTagGroup</option>
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="target">Scope target ID</label>
<InputText id="target" @bind-Value="_form.ScopeId" class="form-control form-control-sm mono"
placeholder="Leave blank for cluster-wide" />
<div class="form-text">
Free text, no picker — a typo saves a grant that resolves to nothing.
<span class="mono">Cluster</span> → leave blank ·
<span class="mono">Namespace</span> → <span class="mono">raw</span> or
<span class="mono">uns</span> ·
<span class="mono">UnsArea</span> → a UnsAreaId ·
<span class="mono">UnsLine</span> → a UnsLineId ·
<span class="mono">Equipment</span> → an EquipmentId ·
<span class="mono">Tag</span> → a TagId (a raw tag; the grant covers the raw
node and every UNS projection of it) ·
<span class="mono">RawFolder</span> → a RawFolderId ·
<span class="mono">RawDriver</span> → a DriverInstanceId ·
<span class="mono">RawDevice</span> → a DeviceId ·
<span class="mono">RawTagGroup</span> → a TagGroupId ·
<span class="mono">FolderSegment</span> → retired v2 kind, resolves nothing.
</div>
</div>
</div>
<div class="mb-3">
@@ -20,29 +20,42 @@
else
{
<div class="alert alert-warning" role="alert">
<strong>These rules are not enforced.</strong>
ACL rows are saved and shipped in every deployment artifact, but the OPC UA server never
evaluates them — the permission evaluator has no production call site. Authoring a grant here
changes nothing about what a client can read or write.
<strong>Only <span class="mono">Cluster</span>-scoped grants are enforced today.</strong>
A Cluster-scoped row confers a server-wide role on its principal:
<span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
(acknowledge / confirm / shelve). No other permission bit is evaluated yet.
<br />
What <em>is</em> enforced today is coarse and fleet-wide: a client needs the
<span class="mono">WriteOperate</span> role to write any tag and
<span class="mono">AlarmAck</span> to acknowledge any alarm, both mapped from LDAP groups via
<span class="mono">Security:Ldap:GroupToRole</span>. Reads, browses, subscriptions and history
reads are <strong>not</strong> restricted at all. See <span class="mono">docs/security.md</span>.
<strong>Narrower grants confer nothing.</strong>
<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
<span class="mono">Tag</span> and the <span class="mono">Raw*</span> kinds are saved and shipped
in every deployment artifact, but stay inert until per-node enforcement lands. That
under-granting is deliberate: honouring such a grant's flags while its <em>scope</em> cannot be
honoured would turn "WriteOperate on one equipment" into server-wide write authority.
<br />
Read, Browse, Subscribe and HistoryRead are <strong>not</strong> gated for any admitted
session, including anonymous.
<br />
Identity is an <strong>API key</strong>, not LDAP: a client presents an OPC UA UserName token
with username <span class="mono">apikey</span> and password
<span class="mono">otua_&lt;keyId&gt;_&lt;secret&gt;</span>, and the key resolves to the
principal named on these rows. Mint keys on the <span class="mono">/apikeys</span> page; LDAP
now covers AdminUI login only. See <span class="mono">docs/security.md</span>.
</div>
<section class="panel notice rise" style="animation-delay:.02s">
ACL rows grant principals specific <span class="mono">NodePermissions</span> on a scope
(a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of
fleet-wide LDAP-group → role mapping; ACLs here are the finer-grained per-node scope.
(the cluster, a namespace, an area, an equipment, a raw device, a tag). A principal is an API
key's principal, or the reserved <span class="mono">anonymous</span> principal that an
anonymous session authenticates as.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count ACL row@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No ACL rows for this cluster — default permissions from the fleet-wide LDAP group mapping apply.</div>
<div style="padding:1rem" class="text-muted">No ACL rows for this cluster — no principal holds <span class="mono">WriteOperate</span> or <span class="mono">AlarmAck</span> here, so writes and alarm acknowledges are refused. Reads, browses, subscriptions and history reads are unaffected.</div>
}
else
{
@@ -231,7 +231,7 @@ public static class ServiceCollectionExtensions
// on the fused central node). AkkaClusterOptions is pure configuration, available before the
// cluster forms — the same source ClusterRoleInfo.ClusterRole itself derives from — so the
// scope is identical. First cluster role wins, in configuration order (matching ClusterRoleInfo).
var clusterRole = clusterOptions.Roles.FirstOrDefault(RoleParser.IsClusterRole);
var clusterRole = RoleParser.ClusterRoleFromRoles(clusterOptions.Roles);
return new ClusterSingletonOptions { Role = clusterRole ?? RoleParser.Driver };
}
}
@@ -61,10 +61,20 @@ public sealed class ArtifactApiKeyStore : IApiKeyStore
Task.FromResult(ClusterGrants(_authority.Current, principal));
/// <summary>
/// Narrows a principal's grants to the Cluster-scoped ones, matching <c>ConfigDbApiKeyStore</c>.
/// Only those confer an interim role (see <see cref="ApiKeyInterimRoleMapper"/>); stage 5 widens
/// both stores to the full grant set once scope is actually enforced.
/// Narrows a principal's grants to the Cluster-scoped ones. Only those confer an interim role
/// (see <see cref="ApiKeyInterimRoleMapper"/>); stage 5 widens both stores to the full grant set
/// once scope is actually enforced.
/// </summary>
/// <remarks>
/// <b>Scope kind is the ONLY narrowing needed here — cluster confinement already happened.</b>
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> kept only the node's own cluster's ACL rows
/// (<c>ClusterFilterMode.ScopeTo</c>, matched <see cref="StringComparison.OrdinalIgnoreCase"/>),
/// so this snapshot cannot contain another cluster's grant. <c>ConfigDbApiKeyStore</c> reads the
/// fleet-wide ConfigDb and therefore has to apply that confinement itself; the two stores agree on
/// the outcome, not on the mechanism. An earlier version of this comment claimed
/// <c>ConfigDbApiKeyStore</c> "filters the same way in SQL" — it did not filter on cluster at all,
/// which was the cross-cluster over-grant this remark now exists to keep straight.
/// </remarks>
private static IReadOnlyList<NodeAcl> ClusterGrants(DataPlaneAuthoritySnapshot snapshot, string principal)
{
var all = snapshot.GrantsFor(principal);
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -16,6 +17,13 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// Reading per request is what makes revocation immediate here, which is why this store is
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node.
/// </para>
/// <para>
/// ⚠️ <b>The ConfigDb holds the WHOLE fleet's ACL rows, not just this node's cluster's.</b> That is
/// the difference from <see cref="ArtifactApiKeyStore"/>, whose snapshot was already narrowed to
/// the node's own cluster by <c>DeploymentArtifact.ParseDataPlaneAuthority</c>. So this store must
/// confine grants to <see cref="ClusterId"/> itself — without that predicate a Cluster-scoped
/// grant authored in ANY cluster in the fleet conferred an interim server-wide role here.
/// </para>
/// </remarks>
public sealed class ConfigDbApiKeyStore : IApiKeyStore
{
@@ -23,7 +31,60 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
/// <summary>Initializes a new instance of the <see cref="ConfigDbApiKeyStore"/> class.</summary>
/// <param name="factory">The config DB context factory.</param>
public ConfigDbApiKeyStore(IDbContextFactory<OtOpcUaConfigDbContext> factory) => _factory = factory;
/// <param name="clusterId">
/// This node's own ClusterId, from <see cref="ResolveClusterId"/>. <see langword="null"/> on a
/// legacy / single-cluster node that carries no <c>cluster-{ClusterId}</c> role — see
/// <see cref="ClusterId"/> for what that means for filtering.
/// </param>
public ConfigDbApiKeyStore(IDbContextFactory<OtOpcUaConfigDbContext> factory, string? clusterId)
{
ArgumentNullException.ThrowIfNull(factory);
_factory = factory;
ClusterId = string.IsNullOrWhiteSpace(clusterId) ? null : clusterId;
}
/// <summary>
/// The node's own ClusterId, or <see langword="null"/> when it carries no cluster-scoped role.
/// </summary>
/// <remarks>
/// A <see langword="null"/> ClusterId applies NO cluster filter, mirroring the artifact path's
/// <c>ClusterFilterMode.None</c> — a single-cluster artifact is applied whole. Failing closed here
/// instead would silently strip every grant on an existing single-cluster deployment, which is a
/// worse failure than the one being fixed and would not be visible until a client was denied.
/// </remarks>
public string? ClusterId { get; }
/// <summary>
/// Resolves the node's ClusterId from its OWN configured roles, e.g. <c>cluster-SITE-A</c> →
/// <c>SITE-A</c>; <see langword="null"/> when no role is cluster-scoped.
/// </summary>
/// <remarks>
/// <para>
/// This is the same derivation <see cref="Cluster.ClusterRoleInfo"/> performs for
/// <c>IClusterRoleInfo.ClusterId</c> — literally so as of Gitea #532, which collapsed four
/// open-coded copies into <see cref="RoleParser.ClusterIdFromRoles"/>. The copies had drifted:
/// three classified the cluster role case-sensitively and one did not, and only one applied
/// the <c>OTOPCUA_ROLES</c> fallback. On a node that hit either gap this method returned
/// <see langword="null"/>, and a null ClusterId applies no filter at all — so the
/// cross-cluster over-grant this store exists to prevent failed <b>open</b>.
/// </para>
/// <para>
/// ⚠️ <b>Takes <see cref="AkkaClusterOptions"/> (pure configuration), NOT
/// <c>IClusterRoleInfo</c> resolved from the service provider.</b> <c>ClusterRoleInfo</c>
/// depends on the <c>ActorSystem</c>; resolving it during host composition recurses into
/// ActorSystem construction and stack-overflows the node at boot while compiling clean (see
/// CLAUDE.md, per-cluster mesh Phase 6). The ClusterId is configuration, so read it from
/// configuration.
/// </para>
/// </remarks>
/// <param name="options">The node's bound cluster options.</param>
/// <returns>The node's ClusterId, or <see langword="null"/> when it carries no cluster-scoped role.</returns>
public static string? ResolveClusterId(AkkaClusterOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return RoleParser.ClusterIdFromRoles(options.Roles);
}
/// <inheritdoc />
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
@@ -53,15 +114,33 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
return await LoadClusterGrantsAsync(db, principal, ct).ConfigureAwait(false);
}
private static async Task<IReadOnlyList<NodeAcl>> LoadClusterGrantsAsync(
private async Task<IReadOnlyList<NodeAcl>> LoadClusterGrantsAsync(
OtOpcUaConfigDbContext db, string principal, CancellationToken ct)
{
// Only cluster-scoped rows can confer an interim role (see ApiKeyInterimRoleMapper), so only
// those are fetched. Stage 5 widens this to the full grant set for per-node evaluation.
return await db.NodeAcls
var rows = await db.NodeAcls
.AsNoTracking()
.Where(a => a.Principal == principal && a.ScopeKind == NodeAclScopeKind.Cluster)
.ToListAsync(ct)
.ConfigureAwait(false);
if (ClusterId is null) return rows;
// The ClusterId match is done HERE rather than as a SQL predicate, unlike the Enabled/expiry
// filters above, because it must be case-insensitive DETERMINISTICALLY. `a.ClusterId == ...` in
// SQL is decided by the database collation — case-insensitive on the usual SQL Server default,
// case-SENSITIVE on a CS collation or on the in-memory provider the tests use — and the two
// spellings genuinely differ: `Cluster:Roles` binds a role verbatim (cluster-SITE-A ⇒ SITE-A)
// while the OTOPCUA_ROLES path goes through RoleParser.Parse, which lower-cases it (⇒ site-a).
// A collation-dependent match would therefore strip every grant on a node configured the second
// way. OrdinalIgnoreCase is the SAME comparison DeploymentArtifact.ParseDataPlaneAuthority uses
// to scope the artifact's grants, so both stores confine identically.
var mine = new List<NodeAcl>(rows.Count);
foreach (var grant in rows)
{
if (string.Equals(grant.ClusterId, ClusterId, StringComparison.OrdinalIgnoreCase)) mine.Add(grant);
}
return mine;
}
}
+9 -1
View File
@@ -376,7 +376,15 @@ if (hasDriver)
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
// on the very next request rather than on the next deployment. Preferred wherever it is available,
// including on a fused admin+driver node.
builder.Services.AddSingleton<IApiKeyStore, ConfigDbApiKeyStore>();
// The ClusterId confines the grants this node honours to its OWN cluster — the ConfigDb holds
// the whole fleet's ACL rows, unlike the artifact a driver-only node reads, which
// ParseDataPlaneAuthority already scoped. Taken from AkkaClusterOptions (pure configuration),
// NOT from IClusterRoleInfo: that depends on the ActorSystem, and the same options-not-services
// rule is what keeps the redundancy-singleton registration below from recursing into ActorSystem
// construction. Null on a node with no cluster-{ClusterId} role ⇒ no filter (legacy behaviour).
builder.Services.AddSingleton<IApiKeyStore>(sp => new ConfigDbApiKeyStore(
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
ConfigDbApiKeyStore.ResolveClusterId(sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)));
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
@@ -4,6 +4,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -46,13 +47,22 @@ public enum ClusterFilterMode
/// lookup filters those, so that a revocation is visible rather than indistinguishable from a
/// missing key.</param>
/// <param name="Grants">The <c>NodeAcl</c> rows scoped to <paramref name="ClusterId"/>.</param>
/// <param name="ScopePaths">
/// Where each grant's scope sits in the address space, resolved from the artifact's own topology.
/// This is why the node needs no ConfigDb to build a permission trie: the artifact already carries
/// every raw and UNS parent link, so the paths are derivable from the same bytes the grants came in.
/// A scope that resolves to nothing is absent here, and the trie builder drops its grant with a
/// diagnostic rather than planting it somewhere unreachable.
/// </param>
public sealed record DataPlaneAuthorityProjection(
string? ClusterId,
IReadOnlyList<ApiKey> Keys,
IReadOnlyList<NodeAcl> Grants)
IReadOnlyList<NodeAcl> Grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths)
{
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
public static readonly DataPlaneAuthorityProjection Empty = new(null, [], []);
public static readonly DataPlaneAuthorityProjection Empty =
new(null, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
}
/// <summary>Resolved scoping decision for a node against an artifact.</summary>
@@ -610,7 +620,8 @@ public static class DeploymentArtifact
grants.Add(acl);
}
return new DataPlaneAuthorityProjection(scope.ClusterId, keys, grants);
return new DataPlaneAuthorityProjection(
scope.ClusterId, keys, grants, BuildAclScopePaths(root, grants));
}
catch (JsonException)
{
@@ -618,6 +629,109 @@ public static class DeploymentArtifact
}
}
/// <summary>
/// Resolve every scope named by <paramref name="grants"/> against the artifact's own raw + UNS
/// topology, using the same <c>RawPathResolver</c> the address space is built through — so an ACL
/// path and the RawPath that forms a node's NodeId cannot drift apart.
/// </summary>
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> BuildAclScopePaths(
JsonElement root,
IReadOnlyList<NodeAcl> grants)
{
if (grants.Count == 0) return new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "RawFolders"))
{
var id = ReadString(el, "RawFolderId");
if (id is null) continue;
folders[id] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id);
}
var drivers = new Dictionary<string, (string? RawFolderId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "DriverInstances"))
{
var id = ReadString(el, "DriverInstanceId");
if (id is null) continue;
drivers[id] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id);
}
var devices = new Dictionary<string, (string DriverInstanceId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "Devices"))
{
var id = ReadString(el, "DeviceId");
var driverId = ReadString(el, "DriverInstanceId");
if (id is null || driverId is null) continue;
devices[id] = (driverId, ReadString(el, "Name") ?? id);
}
var groups = new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "TagGroups"))
{
var id = ReadString(el, "TagGroupId");
var deviceId = ReadString(el, "DeviceId");
if (id is null || deviceId is null) continue;
groups[id] = (ReadNullableString(el, "ParentTagGroupId"), deviceId, ReadString(el, "Name") ?? id);
}
var tags = new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "Tags"))
{
var id = ReadString(el, "TagId");
var deviceId = ReadString(el, "DeviceId");
if (id is null || deviceId is null) continue;
tags[id] = (deviceId, ReadNullableString(el, "TagGroupId"), ReadString(el, "Name") ?? id);
}
var areas = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "UnsAreas"))
{
var id = ReadString(el, "UnsAreaId");
if (id is null) continue;
areas[id] = ReadString(el, "Name") ?? id;
}
var lines = new Dictionary<string, (string UnsAreaId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "UnsLines"))
{
var id = ReadString(el, "UnsLineId");
var areaId = ReadString(el, "UnsAreaId");
if (id is null || areaId is null) continue;
lines[id] = (areaId, ReadString(el, "Name") ?? id);
}
var equipment = new Dictionary<string, (string UnsLineId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "Equipment"))
{
var id = ReadString(el, "EquipmentId");
var lineId = ReadString(el, "UnsLineId");
if (id is null || lineId is null) continue;
equipment[id] = (lineId, ReadString(el, "Name") ?? id);
}
var references = new List<(string EquipmentId, string TagId, string EffectiveName)>();
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
{
var equipmentId = ReadString(el, "EquipmentId");
var tagId = ReadString(el, "TagId");
if (equipmentId is null || tagId is null) continue;
// Effective name = the override when present, else the backing raw tag's own name. This is the
// same rule the address space materializes UNS variables under; a reference whose tag is
// missing from the artifact has no resolvable name and is skipped.
var effective = ReadNullableString(el, "DisplayNameOverride");
if (string.IsNullOrWhiteSpace(effective))
effective = tags.TryGetValue(tagId, out var t) ? t.Name : null;
if (effective is null) continue;
references.Add((equipmentId, tagId, effective));
}
var resolver = new AclScopePathResolver(
folders, drivers, devices, groups, tags, areas, lines, equipment, references);
return resolver.Resolve(grants);
}
/// <summary>Reads one <c>ApiKeys</c> element; null when a field the credential check depends on is
/// missing, so a half-written row can never authenticate.</summary>
private static ApiKey? TryReadApiKey(JsonElement el)
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -533,12 +534,29 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// which is what lets a node recognise a re-applied revision without the hash riding the wire.
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifact));
var snapshot = _dataPlaneAuthority.Apply(
projection.ClusterId, revision, projection.Keys, projection.Grants);
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths);
// unresolvedScopes counts grants narrower than Cluster whose ScopeId named nothing in this
// artifact — a dangling id, or a scope kind with no v3 entity behind it. Those grants will be
// dropped when the trie is built, so surfacing the count here is the operator's only warning
// that a rule they authored is not going to do anything.
var unresolvedScopes = projection.Grants
.Where(g => g.ScopeKind != NodeAclScopeKind.Cluster && !string.IsNullOrEmpty(g.ScopeId))
.Select(g => new AclScopeRef(g.ScopeKind, g.ScopeId!))
.Distinct()
.Count(r => !projection.ScopePaths.ContainsKey(r));
_log.Info(
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants})",
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",
snapshot.GenerationId, projection.ClusterId ?? "(single-cluster)", revision[..12],
projection.Keys.Count, projection.Grants.Count);
projection.Keys.Count, projection.Grants.Count, projection.ScopePaths.Count);
if (unresolvedScopes > 0)
{
_log.Warning(
"OpcUaPublish: {Unresolved} ACL scope(s) in generation {Generation} name nothing in the deployed address space — grants on them will be DROPPED, not applied. Check the ScopeId on those rules",
unresolvedScopes, snapshot.GenerationId);
}
}
catch (Exception ex)
{
@@ -0,0 +1,97 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>
/// Gitea #532 — <c>AkkaClusterOptions.Roles</c> must actually apply the <c>OTOPCUA_ROLES</c> fallback
/// its own XML doc has always described.
/// </summary>
/// <remarks>
/// <para>
/// These go through <c>AddOtOpcUaCluster</c> + a real service provider rather than calling
/// <c>RoleParser.ResolveEffectiveRoles</c> directly, because the defect was never in the parsing —
/// it was that nothing <em>called</em> it on the way to the bound options. A unit test of the
/// helper alone would have passed against the broken tree.
/// </para>
/// <para>
/// The consequence being pinned: <c>BuildClusterOptions</c> feeds <c>Roles</c> straight to Akka as
/// the node's gossiped member roles. Empty there means a node that joins, comes Up, serves OPC UA
/// — and is invisible to <c>RoleLeader</c>, to <c>MembersWithRole("driver")</c>, to every
/// role-scoped singleton, and to <c>ConfigPublishCoordinator</c>'s expected-ack set. The field
/// symptom was "no driver-role nodes are Up" with nothing wrong in the node's own logs
/// (<c>docs/deployments/wonder-app-vd03-makino-z-34184.md</c>).
/// </para>
/// </remarks>
[Collection(RoleEnvironmentCollection.Name)]
public class AkkaClusterOptionsRoleFallbackTests
{
private static AkkaClusterOptions Bind(params (string Key, string Value)[] settings)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(settings.ToDictionary(s => s.Key, s => (string?)s.Value))
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddOtOpcUaCluster(configuration);
return services.BuildServiceProvider().GetRequiredService<IOptions<AkkaClusterOptions>>().Value;
}
[Fact]
public void Roles_fall_back_to_the_environment_when_Cluster_Roles_is_empty()
{
using (new EnvVarScope(RoleParser.RolesEnvVar, "admin,driver"))
{
Bind().Roles.ShouldBe(new[] { "admin", "driver" });
}
}
[Fact]
public void Akka_member_roles_carry_the_fallback()
{
// The end that matters. This is the value handed to Akka.Cluster.Hosting, so an empty array here
// is a role-less member — the exact production shape #532 describes.
using (new EnvVarScope(RoleParser.RolesEnvVar, "driver"))
{
ServiceCollectionExtensions.BuildClusterOptions(Bind()).Roles.ShouldBe(new[] { "driver" });
}
}
[Fact]
public void Configured_roles_win_over_the_environment()
{
// Negative control for the fallback: without this, a PostConfigure that unconditionally
// overwrote Roles from the environment would pass the test above and silently ignore every
// deployment's explicit Cluster:Roles.
using (new EnvVarScope(RoleParser.RolesEnvVar, "admin,driver,cluster-SITE-B"))
{
Bind(("Cluster:Roles:0", "driver"), ("Cluster:Roles:1", "cluster-SITE-A")).Roles
.ShouldBe(new[] { "driver", "cluster-SITE-A" });
}
}
[Fact]
public void No_roles_from_either_source_stays_empty()
{
using (new EnvVarScope(RoleParser.RolesEnvVar, null))
{
Bind().Roles.ShouldBeEmpty();
}
}
[Fact]
public void The_cluster_role_from_the_environment_reaches_the_ClusterId_derivation()
{
// The over-grant fix in ConfigDbApiKeyStore reads its ClusterId off these options, and a null
// ClusterId applies no cluster filter at all — so an env-only node failed OPEN before #532.
using (new EnvVarScope(RoleParser.RolesEnvVar, "driver,cluster-SITE-A"))
{
RoleParser.ClusterIdFromRoles(Bind().Roles).ShouldBe("site-a");
}
}
}
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// applies a deployment, and the deploy times out naming a node that is otherwise healthy. These
/// tests make that misconfiguration a loud host-start failure instead.
/// </summary>
[Collection(RoleEnvironmentCollection.Name)]
public class ConfigSourceOptionsValidatorTests
{
private static ValidateOptionsResult Validate(ConfigSourceOptions o, params string[] roles)
@@ -0,0 +1,44 @@
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>Sets an environment variable for the scope's lifetime and restores its prior value on dispose.</summary>
public sealed class EnvVarScope : IDisposable
{
private readonly string _name;
private readonly string? _previous;
/// <summary>Sets <paramref name="name"/> to <paramref name="value"/> until disposed.</summary>
/// <param name="name">The environment variable name.</param>
/// <param name="value">The value to set, or null to unset.</param>
public EnvVarScope(string name, string? value)
{
_name = name;
_previous = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
}
/// <inheritdoc />
public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
}
/// <summary>
/// Serializes every test class that reads or writes the <c>OTOPCUA_ROLES</c> process environment.
/// </summary>
/// <remarks>
/// ⚠️ <b>Load-bearing since Gitea #532.</b> Environment variables are process-global while xUnit runs
/// test classes in an assembly <i>concurrently</i>, so a class that sets <c>OTOPCUA_ROLES</c> is
/// visible to every class running beside it. That was harmless while
/// <c>SplitTopologyTransportValidator</c> was the only code applying the fallback — its own tests both
/// set and read the variable. It stopped being harmless when <c>ConfigSourceOptionsValidator</c> and
/// <c>TelemetryOptionsValidator</c> started resolving effective roles the same way: their cases that
/// pass NO roles would intermittently observe another class's <c>driver,cluster-SITE-A</c> and change
/// verdict. Any test touching that variable, directly or through
/// <c>RoleParser.ResolveEffectiveRoles</c>, belongs in this collection.
/// </remarks>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class RoleEnvironmentCollection
{
/// <summary>The collection name to put on <c>[Collection]</c>.</summary>
public const string Name = "role-environment";
}
@@ -89,6 +89,12 @@ public sealed class RoleParserTests
[InlineData("driver", false)]
[InlineData("admin", false)]
[InlineData("clusterx", false)]
// Gitea #532: the prefix test is case-insensitive. Cluster:Roles binds verbatim, so an operator's
// 'Cluster-SITE-A' used to classify as an ordinary role — the node then gossiped a cluster-scoped
// role while resolving a null ClusterId for itself.
[InlineData("Cluster-SITE-A", true)]
[InlineData("CLUSTER-main", true)]
[InlineData("Cluster-", false)]
public void IsClusterRole_classifies_correctly(string role, bool expected)
{
RoleParser.IsClusterRole(role).ShouldBe(expected);
@@ -101,6 +107,65 @@ public sealed class RoleParserTests
RoleParser.ClusterIdFromRole("cluster-SITE-A").ShouldBe("SITE-A");
}
/// <summary>The suffix keeps the operator's spelling — consumers compare ClusterIds case-insensitively.</summary>
[Fact]
public void ClusterIdFromRole_preserves_the_authored_case()
{
// Normalizing here would silently rename the Akka role of a running deployment (the docker-dev
// rig configures cluster-MAIN / cluster-SITE-A verbatim), so the case is carried, not folded.
RoleParser.ClusterIdFromRole("Cluster-SITE-A").ShouldBe("SITE-A");
}
/// <summary>Verifies the ClusterId derivation picks the first cluster role, in configuration order.</summary>
[Fact]
public void ClusterIdFromRoles_takes_the_first_cluster_role()
{
RoleParser.ClusterIdFromRoles(["driver", "cluster-SITE-A", "cluster-SITE-B"]).ShouldBe("SITE-A");
RoleParser.ClusterRoleFromRoles(["driver", "cluster-SITE-A"]).ShouldBe("cluster-SITE-A");
RoleParser.ClusterRolesIn(["driver", "cluster-SITE-A", "cluster-SITE-B"]).Count.ShouldBe(2);
}
/// <summary>Verifies a node with no cluster-scoped role derives a null ClusterId rather than throwing.</summary>
[Fact]
public void ClusterIdFromRoles_is_null_for_a_legacy_node()
{
RoleParser.ClusterIdFromRoles(["admin", "driver"]).ShouldBeNull();
RoleParser.ClusterIdFromRoles([]).ShouldBeNull();
RoleParser.ClusterIdFromRoles(null).ShouldBeNull();
}
/// <summary>Verifies the mixed-case derivation gap #532 named: classification folds case, the id does not.</summary>
[Fact]
public void ClusterIdFromRoles_resolves_a_mixed_case_cluster_role()
{
// Before #532 this returned null, and a null ClusterId applies NO cluster filter — so
// ConfigDbApiKeyStore's cross-cluster guard failed open on exactly this configuration.
RoleParser.ClusterIdFromRoles(["driver", "Cluster-SITE-A"]).ShouldBe("SITE-A");
}
/// <summary>Verifies Cluster:Roles wins outright over the environment fallback.</summary>
[Fact]
public void ResolveEffectiveRoles_prefers_the_configured_list()
{
RoleParser.ResolveEffectiveRoles(["admin"], "driver,cluster-SITE-A").ShouldBe(new[] { "admin" });
}
/// <summary>Verifies an empty or absent Cluster:Roles falls back to OTOPCUA_ROLES.</summary>
[Fact]
public void ResolveEffectiveRoles_falls_back_to_the_environment()
{
RoleParser.ResolveEffectiveRoles([], "driver,cluster-SITE-A")
.ShouldBe(new[] { "driver", "cluster-site-a" });
RoleParser.ResolveEffectiveRoles(null, "admin").ShouldBe(new[] { "admin" });
}
/// <summary>Verifies neither source naming a role yields an empty list rather than a null or a throw.</summary>
[Fact]
public void ResolveEffectiveRoles_with_neither_source_is_empty()
{
RoleParser.ResolveEffectiveRoles(null, null).ShouldBeEmpty();
}
/// <summary>Verifies the three well-known role constants match the historical literal values.</summary>
[Fact]
public void Well_known_role_constants()
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// that misconfiguration a loud host-start failure instead. A node with no cluster-role is exempt
/// (legacy / single-mesh / test), and the validator is a no-op for it.
/// </summary>
[Collection(RoleEnvironmentCollection.Name)]
public class SplitTopologyTransportValidatorTests
{
private static ValidateOptionsResult Validate(
@@ -248,20 +249,4 @@ public class SplitTopologyTransportValidatorTests
telemetryDialMode: null,
"cluster-SITE-A").Succeeded.ShouldBeTrue();
}
/// <summary>Sets an environment variable for the scope's lifetime and restores its prior value on dispose.</summary>
private sealed class EnvVarScope : IDisposable
{
private readonly string _name;
private readonly string? _previous;
public EnvVarScope(string name, string? value)
{
_name = name;
_previous = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
}
public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
}
}
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// telemetry, and whoever is watching sees nothing with no stack trace to point at. These tests
/// make that misconfiguration a loud host-start failure instead.
/// </summary>
[Collection(RoleEnvironmentCollection.Name)]
public class TelemetryOptionsValidatorTests
{
private static ValidateOptionsResult Validate(TelemetryOptions o, params string[] roles)
@@ -112,6 +113,7 @@ public class TelemetryOptionsValidatorTests
/// <summary>
/// Mirrors <see cref="TelemetryOptionsValidatorTests"/> for the central dial-side options.
/// </summary>
[Collection(RoleEnvironmentCollection.Name)]
public class TelemetryDialOptionsValidatorTests
{
private static ValidateOptionsResult Validate(TelemetryDialOptions o) =>
@@ -0,0 +1,459 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// The #520 stage-3 deploy gate for <see cref="NodeAcl"/> scopes. An unresolvable scope is dropped by
/// the permission-trie builder and the deploy still seals green, so the row reads as a live grant in the
/// AdminUI while authorizing nothing — these pin that it fails the deploy instead.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorNodeAclScopeTests
{
/// <summary>The cluster every ACL row under test is authored in.</summary>
private const string Own = "c";
/// <summary>A second, fully-populated cluster in the same snapshot — the cross-cluster counterparty.</summary>
private const string Other = "other";
private const string OwnEquipId = "EQ-000000000000";
private const string OtherEquipId = "EQ-0000000b0000";
private static NodeAcl Acl(NodeAclScopeKind kind, string? scopeId, string id = "acl-1") => new()
{
NodeAclId = id,
ClusterId = Own,
Principal = "svc-line3",
ScopeKind = kind,
ScopeId = scopeId,
PermissionFlags = NodePermissions.Read,
};
/// <summary>
/// A draft carrying one of each scoped entity in <see cref="Own"/> and a mirrored set in
/// <see cref="Other"/>. Two clusters, not one, so that a cross-cluster id is genuinely present in the
/// snapshot: if the rule's lookups were cluster-filtered such an id would present as merely
/// unresolved and the containment check could never fire.
/// </summary>
private static DraftSnapshot Draft(params NodeAcl[] acls) => new()
{
GenerationId = 1,
ClusterId = Own,
NodeAcls = acls,
RawFolders =
[
new RawFolder { RawFolderId = "rf-1", ClusterId = Own, Name = "plant" },
new RawFolder { RawFolderId = "rf-2", ClusterId = Other, Name = "plant2" },
],
DriverInstances =
[
new DriverInstance
{
DriverInstanceId = "di-1", ClusterId = Own, Name = "modbus",
DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-1",
},
new DriverInstance
{
DriverInstanceId = "di-2", ClusterId = Other, Name = "modbus2",
DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-2",
},
],
Devices =
[
new Device { DeviceId = "dev-1", DriverInstanceId = "di-1", Name = "dev1", DeviceConfig = "{}" },
new Device { DeviceId = "dev-2", DriverInstanceId = "di-2", Name = "dev2", DeviceConfig = "{}" },
],
TagGroups =
[
new TagGroup { TagGroupId = "tg-1", DeviceId = "dev-1", Name = "group1" },
new TagGroup { TagGroupId = "tg-2", DeviceId = "dev-2", Name = "group2" },
],
UnsAreas =
[
new UnsArea { UnsAreaId = "area-1", ClusterId = Own, Name = "north" },
new UnsArea { UnsAreaId = "area-2", ClusterId = Other, Name = "south" },
],
UnsLines =
[
new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line3" },
new UnsLine { UnsLineId = "line-2", UnsAreaId = "area-2", Name = "line4" },
],
Equipment =
[
new Equipment
{
EquipmentUuid = Guid.Parse("00000000-0000-0000-0000-0000000000aa"),
EquipmentId = OwnEquipId,
Name = "filler", UnsLineId = "line-1", MachineCode = "m",
},
new Equipment
{
EquipmentUuid = Guid.Parse("0000000b-0000-0000-0000-000000000000"),
EquipmentId = OtherEquipId,
Name = "packer", UnsLineId = "line-2", MachineCode = "m",
},
],
Tags =
[
new Tag
{
TagId = "tag-1", DeviceId = "dev-1", Name = "speed",
DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
},
new Tag
{
TagId = "tag-2", DeviceId = "dev-2", Name = "speed2",
DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
},
],
};
/// <summary>
/// The fixture itself must trip no rule, or a test asserting on the whole error list is really
/// asserting on unrelated noise. This also pins both hardcoded EquipmentIds against the canonical
/// derivation, which a later edit to the fixture's UUIDs would otherwise silently break.
/// </summary>
[Fact]
public void The_fixture_with_no_acl_rows_is_clean()
{
DraftValidator.Validate(Draft()).ShouldBeEmpty();
DraftValidator.DeriveEquipmentId(Guid.Parse("00000000-0000-0000-0000-0000000000aa")).ShouldBe(OwnEquipId);
DraftValidator.DeriveEquipmentId(Guid.Parse("0000000b-0000-0000-0000-000000000000")).ShouldBe(OtherEquipId);
}
/// <summary>
/// The row shape the trie builder drops without a word: a narrower kind with nothing to narrow to.
/// </summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void A_non_Cluster_kind_without_a_ScopeId_is_a_deploy_error(string? scopeId)
{
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Equipment, scopeId)));
errors.ShouldContain(e => e.Code == "NodeAclScopeIdMissing" && e.Context == "acl-1");
}
/// <summary>
/// A Cluster grant ignores ScopeId, so a populated one means the author picked the wrong kind and the
/// grant is <em>broader</em> than intended — the direction that must never pass silently.
/// </summary>
[Fact]
public void A_Cluster_kind_carrying_a_ScopeId_is_a_deploy_error()
{
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Cluster, "EQ-000000000000")));
errors.ShouldContain(e => e.Code == "NodeAclScopeIdUnexpected" && e.Context == "acl-1");
}
[Fact]
public void A_Cluster_kind_with_no_ScopeId_passes()
{
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Cluster, null)))
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
}
/// <summary>
/// One case per scoped kind: a well-formed id that names no such entity must fail. Kept exhaustive
/// because the kind→collection mapping is hand-written, and a kind wired to the wrong collection
/// would still "work" for every id that happens to exist in both.
/// </summary>
[Theory]
[InlineData(NodeAclScopeKind.UnsArea)]
[InlineData(NodeAclScopeKind.UnsLine)]
[InlineData(NodeAclScopeKind.Equipment)]
[InlineData(NodeAclScopeKind.Tag)]
[InlineData(NodeAclScopeKind.RawFolder)]
[InlineData(NodeAclScopeKind.RawDriver)]
[InlineData(NodeAclScopeKind.RawDevice)]
[InlineData(NodeAclScopeKind.RawTagGroup)]
public void A_ScopeId_naming_no_such_entity_is_a_deploy_error(NodeAclScopeKind kind)
{
var errors = DraftValidator.Validate(Draft(Acl(kind, "does-not-exist")));
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
}
/// <summary>
/// The counterpart to the theory above: each kind pointed at the id from its OWN collection must
/// pass. Without this the rule could reject everything and still look correct.
/// </summary>
[Theory]
[InlineData(NodeAclScopeKind.UnsArea, "area-1")]
[InlineData(NodeAclScopeKind.UnsLine, "line-1")]
[InlineData(NodeAclScopeKind.Equipment, OwnEquipId)]
[InlineData(NodeAclScopeKind.Tag, "tag-1")]
[InlineData(NodeAclScopeKind.RawFolder, "rf-1")]
[InlineData(NodeAclScopeKind.RawDriver, "di-1")]
[InlineData(NodeAclScopeKind.RawDevice, "dev-1")]
[InlineData(NodeAclScopeKind.RawTagGroup, "tg-1")]
public void A_ScopeId_naming_a_real_entity_of_its_own_kind_passes(NodeAclScopeKind kind, string scopeId)
{
DraftValidator.Validate(Draft(Acl(kind, scopeId)))
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
}
/// <summary>
/// The containment check, one case per kind. Each id is real and resolves to a real trie path — it
/// just belongs to another cluster, so the grant would confer authority across a cluster boundary.
/// Three of these kinds carry ClusterId directly; the other five are only reachable by walking a
/// parent chain, which is why every kind is covered rather than a representative one.
/// </summary>
[Theory]
[InlineData(NodeAclScopeKind.UnsArea, "area-2")]
[InlineData(NodeAclScopeKind.UnsLine, "line-2")]
[InlineData(NodeAclScopeKind.Equipment, OtherEquipId)]
[InlineData(NodeAclScopeKind.Tag, "tag-2")]
[InlineData(NodeAclScopeKind.RawFolder, "rf-2")]
[InlineData(NodeAclScopeKind.RawDriver, "di-2")]
[InlineData(NodeAclScopeKind.RawDevice, "dev-2")]
[InlineData(NodeAclScopeKind.RawTagGroup, "tg-2")]
public void A_ScopeId_naming_an_entity_in_another_cluster_is_a_deploy_error(
NodeAclScopeKind kind, string scopeId)
{
var errors = DraftValidator.Validate(Draft(Acl(kind, scopeId)));
errors.ShouldContain(e => e.Code == "NodeAclScopeCrossCluster" && e.Context == "acl-1");
}
/// <summary>
/// The trap this check exists to avoid: if the lookups were cluster-filtered, a cross-cluster id
/// would present as unresolved, the operator would be told to fix a typo in an id that is not a typo,
/// and the containment rule would never fire at all. The two codes must be mutually exclusive.
/// </summary>
[Theory]
[InlineData(NodeAclScopeKind.UnsArea, "area-2")]
[InlineData(NodeAclScopeKind.UnsLine, "line-2")]
[InlineData(NodeAclScopeKind.Equipment, OtherEquipId)]
[InlineData(NodeAclScopeKind.Tag, "tag-2")]
[InlineData(NodeAclScopeKind.RawFolder, "rf-2")]
[InlineData(NodeAclScopeKind.RawDriver, "di-2")]
[InlineData(NodeAclScopeKind.RawDevice, "dev-2")]
[InlineData(NodeAclScopeKind.RawTagGroup, "tg-2")]
public void A_cross_cluster_scope_is_never_also_reported_as_unresolved(
NodeAclScopeKind kind, string scopeId)
{
var errors = DraftValidator.Validate(Draft(Acl(kind, scopeId)));
errors.ShouldNotContain(e => e.Code == "NodeAclScopeUnresolved");
errors.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)).ShouldHaveSingleItem();
}
/// <summary>
/// Gitea #533 — the gate's own identifier comparisons follow the same rule as the resolver's:
/// identifiers compare <c>OrdinalIgnoreCase</c>. Both were ordinal, which put the gate and the
/// runtime into disagreement in the direction that costs the most: the deploy gate would reject a
/// whole configuration as cross-cluster over a <c>ClusterId</c> case difference that
/// <c>PermissionTrieBuilder</c> — which has always matched case-insensitively — would have accepted.
/// </summary>
[Fact]
public void A_case_drifted_ClusterId_is_not_reported_as_cross_cluster()
{
var acl = Acl(NodeAclScopeKind.RawDevice, "dev-1");
acl.ClusterId = Own.ToUpperInvariant();
var errors = DraftValidator.Validate(Draft(acl));
errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster");
errors.ShouldNotContain(e => e.Code == "NodeAclScopeUnresolved");
}
/// <summary>
/// The same rule applied to the <c>ScopeId</c> lookups, so a row this gate passes is a row
/// <c>AclScopePathResolver</c> resolves — the property the gate's whole value rests on. A gate
/// stricter than the resolver blocks deploys that would have worked; a gate looser than it lets
/// through the silently-dropped grants it exists to catch.
/// </summary>
[Fact]
public void A_case_drifted_ScopeId_resolves_at_the_gate_too()
{
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "DEV-1")))
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
}
/// <summary>Negative control: relaxing case must not stop a genuinely foreign cluster from being caught.</summary>
[Fact]
public void A_genuinely_different_ClusterId_is_still_cross_cluster()
{
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "dev-2")))
.ShouldContain(e => e.Code == "NodeAclScopeCrossCluster");
}
/// <summary>The error must name both clusters — which one the grant is in and which one owns the
/// entity — because "re-author under the other cluster" and "repoint at the local entity" are different
/// fixes and the operator picks between them from this message alone.</summary>
[Fact]
public void The_cross_cluster_message_names_both_clusters()
{
var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "dev-2")))
.First(e => e.Code == "NodeAclScopeCrossCluster");
error.Message.ShouldContain($"'{Own}'");
error.Message.ShouldContain($"'{Other}'");
}
/// <summary>
/// An entity whose parent link dangles belongs to no cluster, so containment is unknowable — and it
/// resolves to no scope path either, which is the unresolved case. It must report that, once, rather
/// than a cross-cluster error built on a null owner.
/// </summary>
[Fact]
public void A_device_whose_driver_is_missing_reports_unresolved_only()
{
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = Own,
NodeAcls = [Acl(NodeAclScopeKind.RawDevice, "dev-orphan")],
Devices =
[
new Device
{
DeviceId = "dev-orphan", DriverInstanceId = "di-gone",
Name = "orphan", DeviceConfig = "{}",
},
],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster");
}
/// <summary>The UNS counterpart, and a two-hop break rather than one: Equipment reaches its cluster
/// through UnsLine and only then UnsArea, so a missing line strands it just as a missing driver strands
/// a device.</summary>
[Fact]
public void An_equipment_whose_line_is_missing_reports_unresolved_only()
{
var uuid = Guid.Parse("00000000-0000-0000-0000-0000000000aa");
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = Own,
NodeAcls = [Acl(NodeAclScopeKind.Equipment, OwnEquipId)],
Equipment =
[
new Equipment
{
EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid),
Name = "filler", UnsLineId = "line-gone", MachineCode = "m",
},
],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster");
}
/// <summary>
/// ScopeId is a bare string with no foreign key, so a real id of the <em>wrong</em> kind is exactly
/// the mistake that would otherwise hand a grant another entity's subtree.
/// </summary>
[Fact]
public void A_real_id_of_the_wrong_kind_is_still_unresolved()
{
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.UnsArea, "dev-1")));
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
}
/// <summary>Both realm tokens and both full namespace URIs are accepted, in any case — matching
/// <c>NodeAclRealms.TryCanonicalize</c>, which is what actually resolves the row at build time.</summary>
[Theory]
[InlineData("raw")]
[InlineData("uns")]
[InlineData("RAW")]
[InlineData("Uns")]
[InlineData(V3NodeIds.RawNamespaceUri)]
[InlineData(V3NodeIds.UnsNamespaceUri)]
[InlineData(" raw ")]
public void A_Namespace_scope_naming_a_realm_passes(string scopeId)
{
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Namespace, scopeId)))
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
}
/// <summary>The server has exactly two namespaces; anything else names none of them.</summary>
[Theory]
[InlineData("ns=2")]
[InlineData("https://zb.com/otopcua/ns")] // the retired v2 single namespace
[InlineData("equipment")]
public void A_Namespace_scope_naming_no_realm_is_a_deploy_error(string scopeId)
{
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Namespace, scopeId)));
errors.ShouldContain(e => e.Code == "NodeAclNamespaceInvalid" && e.Context == "acl-1");
}
/// <summary>
/// FolderSegment survives in the enum only so later ordinals do not shift; it has no v3 entity and
/// can never resolve, so the error has to name the replacement rather than just say "unresolved".
/// </summary>
[Fact]
public void The_retired_FolderSegment_kind_is_a_deploy_error_naming_RawFolder()
{
var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.FolderSegment, "some-folder")))
.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal))
.ShouldHaveSingleItem();
error.Code.ShouldBe("NodeAclScopeKindRetired");
error.Context.ShouldBe("acl-1");
error.Message.ShouldContain("RawFolder");
}
/// <summary>
/// A FolderSegment row with no ScopeId is both retired and missing an id. The retirement is the
/// actionable fact — the kind can never resolve whatever its ScopeId — so telling the operator to
/// supply one would send them to fix the wrong thing.
/// </summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void FolderSegment_retirement_wins_over_the_missing_ScopeId_error(string? scopeId)
{
var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.FolderSegment, scopeId)))
.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal))
.ShouldHaveSingleItem();
error.Code.ShouldBe("NodeAclScopeKindRetired");
}
/// <summary>
/// An ordinal outside the enum comes from a hand-edited row or a stale schema. Every other check
/// switches on the kind, so it must be rejected up front rather than falling into a default branch.
/// </summary>
[Fact]
public void An_undefined_ScopeKind_ordinal_is_a_deploy_error()
{
var errors = DraftValidator.Validate(Draft(Acl((NodeAclScopeKind)99, "anything")));
errors.ShouldContain(e => e.Code == "NodeAclScopeKindUndefined" && e.Context == "acl-1");
}
/// <summary>Every bad row is reported in one pass — the validator's stated contract, and what lets an
/// operator fix a batch of ACLs in one edit instead of one deploy attempt each.</summary>
[Fact]
public void All_offending_rows_are_reported_in_one_pass()
{
var errors = DraftValidator.Validate(Draft(
Acl(NodeAclScopeKind.Equipment, null, "acl-a"),
Acl(NodeAclScopeKind.Cluster, OwnEquipId, "acl-b"),
Acl(NodeAclScopeKind.RawDevice, "nope", "acl-c"),
Acl(NodeAclScopeKind.RawDevice, "dev-2", "acl-d")));
errors.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal))
.Select(e => e.Context)
.ShouldBe(["acl-a", "acl-b", "acl-c", "acl-d"], ignoreOrder: true);
}
}
@@ -0,0 +1,330 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// Coverage for <see cref="AclScopePathResolver"/> — the producer that turns an ACL row's
/// <c>ScopeId</c> into the trie path(s) a grant on it must be planted at (#520 stage 3).
/// </summary>
/// <remarks>
/// The fixture is deliberately a <b>deeply nested</b> raw tree — two nested folders and two nested
/// tag groups — because the shape that broke the previous design is exactly the one a shallow
/// fixture cannot express. A five-level ladder resolves the shallowest possible raw tree correctly
/// and silently truncates this one.
/// </remarks>
public sealed class AclScopePathResolverTests
{
// Plant/Line1 (nested folders) → ModbusDrv → dev1 → Fast/Inner (nested groups) → Speed
private static AclScopePathResolver Fixture() => new(
folders: new Dictionary<string, (string? ParentId, string Name)>
{
["RF-plant"] = (null, "Plant"),
["RF-line1"] = ("RF-plant", "Line1"),
},
drivers: new Dictionary<string, (string? RawFolderId, string Name)>
{
["DI-modbus"] = ("RF-line1", "ModbusDrv"),
["DI-rootlevel"] = (null, "RootDrv"),
},
devices: new Dictionary<string, (string DriverInstanceId, string Name)>
{
["DV-1"] = ("DI-modbus", "dev1"),
},
groups: new Dictionary<string, (string? ParentId, string DeviceId, string Name)>
{
["TG-fast"] = (null, "DV-1", "Fast"),
["TG-inner"] = ("TG-fast", "DV-1", "Inner"),
},
tags: new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>
{
["TAG-speed"] = ("DV-1", "TG-inner", "Speed"),
["TAG-bare"] = ("DV-1", null, "Bare"),
},
areas: new Dictionary<string, string> { ["UA-w"] = "West" },
lines: new Dictionary<string, (string UnsAreaId, string Name)> { ["UL-1"] = ("UA-w", "L1") },
equipment: new Dictionary<string, (string UnsLineId, string Name)>
{
["EQ-mixer"] = ("UL-1", "Mixer"),
["EQ-filler"] = ("UL-1", "Filler"),
},
references:
[
("EQ-mixer", "TAG-speed", "MixerSpeed"),
("EQ-filler", "TAG-speed", "FillerSpeed"),
]);
private static IReadOnlyList<string> Single(NodeAclScopeKind kind, string scopeId) =>
Fixture().ResolveOne(kind, scopeId).ShouldHaveSingleItem().Segments;
[Fact]
public void A_nested_raw_folder_resolves_through_its_whole_ancestor_chain()
{
Single(NodeAclScopeKind.RawFolder, "RF-line1").ShouldBe(["raw", "Plant", "Line1"]);
}
[Fact]
public void A_raw_device_resolves_through_folders_and_its_driver()
{
Single(NodeAclScopeKind.RawDevice, "DV-1").ShouldBe(["raw", "Plant", "Line1", "ModbusDrv", "dev1"]);
}
[Fact]
public void A_nested_tag_group_resolves_through_its_own_ancestor_chain_too()
{
// Both nesting axes at once — the folder chain AND the group chain. This is the case a
// fixed-depth scope model cannot represent at all: it needs seven segments below the realm.
Single(NodeAclScopeKind.RawTagGroup, "TG-inner")
.ShouldBe(["raw", "Plant", "Line1", "ModbusDrv", "dev1", "Fast", "Inner"]);
}
[Fact]
public void Every_container_path_is_a_true_prefix_of_the_tag_beneath_it()
{
// The invariant the trie walk depends on: a grant on a container only cascades to the tags below
// it if the container's path is a genuine prefix of theirs. If the container paths were composed
// in a different order from the tag path — the obvious way to get this wrong, since the tag path
// comes from RawPathResolver and the container paths are composed here — a container grant would
// land on a sibling branch and silently protect nothing.
var resolver = Fixture();
var tag = resolver.ResolveOne(NodeAclScopeKind.Tag, "TAG-speed")
.First(p => p.Segments[0] == NodeAclRealms.Raw).Segments;
foreach (var (kind, id) in new[]
{
(NodeAclScopeKind.RawFolder, "RF-plant"),
(NodeAclScopeKind.RawFolder, "RF-line1"),
(NodeAclScopeKind.RawDriver, "DI-modbus"),
(NodeAclScopeKind.RawDevice, "DV-1"),
(NodeAclScopeKind.RawTagGroup, "TG-fast"),
(NodeAclScopeKind.RawTagGroup, "TG-inner"),
})
{
var container = resolver.ResolveOne(kind, id).ShouldHaveSingleItem().Segments;
container.Count.ShouldBeLessThan(tag.Count, $"{kind} {id}");
tag.Take(container.Count).ShouldBe(container, $"{kind} {id} is not a prefix of the tag path");
}
}
[Fact]
public void A_tag_resolves_to_its_raw_node_AND_every_UNS_projection_of_it()
{
// A raw tag referenced by two equipments is reachable at three NodeIds, and a write through any
// of them lands on the same driver reference. A grant covering only the raw node would be
// bypassable through either UNS NodeId — so all three must be planted.
var paths = Fixture().ResolveOne(NodeAclScopeKind.Tag, "TAG-speed")
.Select(p => p.Segments)
.ToList();
paths.Count.ShouldBe(3);
paths.ShouldContain(s => s.SequenceEqual(
new[] { "raw", "Plant", "Line1", "ModbusDrv", "dev1", "Fast", "Inner", "Speed" }));
paths.ShouldContain(s => s.SequenceEqual(new[] { "uns", "West", "L1", "Mixer", "MixerSpeed" }));
paths.ShouldContain(s => s.SequenceEqual(new[] { "uns", "West", "L1", "Filler", "FillerSpeed" }));
}
[Fact]
public void A_tag_with_no_UNS_reference_resolves_to_its_raw_node_only()
{
// The positive control for the test above: it proves the three-path result came from the two
// references rather than from the resolver always emitting extra paths.
Single(NodeAclScopeKind.Tag, "TAG-bare")
.ShouldBe(["raw", "Plant", "Line1", "ModbusDrv", "dev1", "Bare"]);
}
[Fact]
public void A_driver_at_the_cluster_root_has_no_folder_segments()
{
Single(NodeAclScopeKind.RawDriver, "DI-rootlevel").ShouldBe(["raw", "RootDrv"]);
}
[Theory]
[InlineData(NodeAclScopeKind.UnsArea, "UA-w", new[] { "uns", "West" })]
[InlineData(NodeAclScopeKind.UnsLine, "UL-1", new[] { "uns", "West", "L1" })]
[InlineData(NodeAclScopeKind.Equipment, "EQ-mixer", new[] { "uns", "West", "L1", "Mixer" })]
public void UNS_scopes_resolve_to_their_name_path(NodeAclScopeKind kind, string id, string[] expected)
{
Single(kind, id).ShouldBe(expected);
}
[Theory]
[InlineData("raw", "raw")]
[InlineData("RAW", "raw")]
[InlineData("uns", "uns")]
[InlineData("https://zb.com/otopcua/raw", "raw")]
[InlineData("https://zb.com/otopcua/uns", "uns")]
public void A_namespace_scope_accepts_the_token_or_the_full_uri(string scopeId, string expected)
{
Single(NodeAclScopeKind.Namespace, scopeId).ShouldBe([expected]);
}
[Fact]
public void A_namespace_scope_naming_no_realm_resolves_to_nothing()
{
Fixture().ResolveOne(NodeAclScopeKind.Namespace, "https://zb.com/otopcua/ns").ShouldBeEmpty();
}
[Theory]
[InlineData(NodeAclScopeKind.RawFolder, "RF-nope")]
[InlineData(NodeAclScopeKind.RawDriver, "DI-nope")]
[InlineData(NodeAclScopeKind.RawDevice, "DV-nope")]
[InlineData(NodeAclScopeKind.RawTagGroup, "TG-nope")]
[InlineData(NodeAclScopeKind.Tag, "TAG-nope")]
[InlineData(NodeAclScopeKind.UnsArea, "UA-nope")]
[InlineData(NodeAclScopeKind.UnsLine, "UL-nope")]
[InlineData(NodeAclScopeKind.Equipment, "EQ-nope")]
public void A_dangling_scope_id_resolves_to_nothing(NodeAclScopeKind kind, string scopeId)
{
// Resolving to nothing is what makes the builder DROP the grant with a diagnostic. The dangerous
// alternative — inventing a path — would plant an operator's grant on some other node.
Fixture().ResolveOne(kind, scopeId).ShouldBeEmpty();
}
[Fact]
public void The_retired_FolderSegment_kind_resolves_to_nothing()
{
// It described a v2 hierarchy that v3 deleted. Deliberately unresolvable so a surviving row
// produces a diagnostic naming it, rather than a grant that quietly does nothing.
Fixture().ResolveOne(NodeAclScopeKind.FolderSegment, "RF-line1").ShouldBeEmpty();
}
[Fact]
public void A_corrupt_parent_cycle_terminates_instead_of_hanging()
{
// Folder nesting is unbounded by design, so the walk cannot be bounded by a schema limit — only
// by a cycle guard. Without one a corrupt artifact would hang the deploy-apply thread rather than
// dropping one grant.
var resolver = new AclScopePathResolver(
folders: new Dictionary<string, (string? ParentId, string Name)>
{
["A"] = ("B", "A"),
["B"] = ("A", "B"),
},
drivers: new Dictionary<string, (string? RawFolderId, string Name)>(),
devices: new Dictionary<string, (string DriverInstanceId, string Name)>(),
groups: new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(),
tags: new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(),
areas: new Dictionary<string, string>(),
lines: new Dictionary<string, (string UnsAreaId, string Name)>(),
equipment: new Dictionary<string, (string UnsLineId, string Name)>(),
references: []);
resolver.ResolveOne(NodeAclScopeKind.RawFolder, "A").ShouldBeEmpty();
}
[Fact]
public void Resolve_keys_on_the_kind_as_well_as_the_id()
{
// ScopeId is a bare string with no FK and no type discriminator; ids are prefixed only by
// convention. Keying the lookup on the id alone would let one entity's id collide with another's
// and hand a grant the wrong node's paths — a silent wrong-node authorization.
var resolver = new AclScopePathResolver(
folders: new Dictionary<string, (string? ParentId, string Name)> { ["dup"] = (null, "AsFolder") },
drivers: new Dictionary<string, (string? RawFolderId, string Name)>(),
devices: new Dictionary<string, (string DriverInstanceId, string Name)>(),
groups: new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(),
tags: new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(),
areas: new Dictionary<string, string> { ["dup"] = "AsArea" },
lines: new Dictionary<string, (string UnsAreaId, string Name)>(),
equipment: new Dictionary<string, (string UnsLineId, string Name)>(),
references: []);
var resolved = resolver.Resolve(
[
Acl("acl-1", NodeAclScopeKind.RawFolder, "dup"),
Acl("acl-2", NodeAclScopeKind.UnsArea, "dup"),
]);
resolved[new AclScopeRef(NodeAclScopeKind.RawFolder, "dup")]
.ShouldHaveSingleItem().Segments.ShouldBe(["raw", "AsFolder"]);
resolved[new AclScopeRef(NodeAclScopeKind.UnsArea, "dup")]
.ShouldHaveSingleItem().Segments.ShouldBe(["uns", "AsArea"]);
}
[Fact]
public void Resolve_omits_cluster_scoped_and_unresolvable_rows()
{
var resolved = Fixture().Resolve(
[
Acl("acl-cluster", NodeAclScopeKind.Cluster, null),
Acl("acl-dangling", NodeAclScopeKind.Equipment, "EQ-nope"),
Acl("acl-good", NodeAclScopeKind.Equipment, "EQ-mixer"),
]);
resolved.Count.ShouldBe(1);
resolved.ShouldContainKey(new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer"));
}
[Theory]
[InlineData(NodeAclScopeKind.RawFolder, "rf-line1")]
[InlineData(NodeAclScopeKind.RawDevice, "dv-1")]
[InlineData(NodeAclScopeKind.Equipment, "eq-MIXER")]
[InlineData(NodeAclScopeKind.Tag, "TAG-SPEED")]
public void A_case_drifted_scope_id_still_resolves(NodeAclScopeKind kind, string scopeId)
{
// Gitea #533 — the identifier half of the rule. ScopeId is an opaque handle, and it is operator
// free-text in the ACL editor until the stage-7 picker lands, so two spellings always mean one
// entity. Ordinal lookups made a case slip drop EVERY grant on that scope: fail-closed and
// diagnosed, but indistinguishable from a genuine typo and invisible to the author.
Fixture().ResolveOne(kind, scopeId).ShouldNotBeEmpty();
}
[Fact]
public void A_case_drifted_scope_id_resolves_to_the_SAME_paths_as_the_authored_one()
{
// Stronger than "resolves to something": the paths must be identical, because the segments are
// entity NAMES and must keep their own spelling. A lookup that folded case into the emitted path
// would produce an ACL path that no longer matches the RawPath forming the node's NodeId.
var drifted = Fixture().ResolveOne(NodeAclScopeKind.RawDevice, "dv-1").ShouldHaveSingleItem();
var authored = Fixture().ResolveOne(NodeAclScopeKind.RawDevice, "DV-1").ShouldHaveSingleItem();
drifted.Segments.ShouldBe(authored.Segments);
drifted.Segments.ShouldBe(["raw", "Plant", "Line1", "ModbusDrv", "dev1"]);
}
[Fact]
public void A_case_drifted_scope_ref_hits_the_same_dictionary_entry()
{
// The key type's own equality, which is what the trie builder looks the resolver's output up by.
// Resolver and builder can be fed from different snapshots (EF at the deploy gate, artifact JSON
// on the node) — the whole reason this resolver is input-shape agnostic — so their agreement
// cannot rest on the two sides holding the same string instances.
var resolved = Fixture().Resolve([Acl("acl-1", NodeAclScopeKind.Equipment, "EQ-mixer")]);
resolved.ShouldContainKey(new AclScopeRef(NodeAclScopeKind.Equipment, "eq-MIXER"));
new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer")
.ShouldBe(new AclScopeRef(NodeAclScopeKind.Equipment, "eq-mixer"));
}
[Fact]
public void Case_insensitive_ids_do_NOT_merge_different_scope_kinds()
{
// Negative control for the widening: relaxing the id comparison must not relax the kind, or the
// wrong-node authorization the composite key exists to prevent comes back through the case door.
new AclScopeRef(NodeAclScopeKind.RawFolder, "dup")
.ShouldNotBe(new AclScopeRef(NodeAclScopeKind.UnsArea, "DUP"));
}
[Fact]
public void A_case_drifted_tag_id_still_reaches_every_UNS_projection()
{
// The reference scan is a linear compare rather than a dictionary lookup, so it needed the rule
// applied separately — and this is the path where missing it is worst: a Tag grant that resolved
// its raw path but none of its UNS paths would leave the tag writable through the UNS NodeId it
// silently skipped.
Fixture().ResolveOne(NodeAclScopeKind.Tag, "tag-speed").Count.ShouldBe(3);
}
private static NodeAcl Acl(string id, NodeAclScopeKind kind, string? scopeId) => new()
{
NodeAclId = id,
ClusterId = "c1",
Principal = "p",
ScopeKind = kind,
ScopeId = scopeId,
PermissionFlags = NodePermissions.Read,
};
}
@@ -14,6 +14,14 @@ public sealed class DataPlaneAuthorityStateTests
{
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
/// <summary>
/// These tests are about key lookup and the generation counter, not about scope resolution, so
/// they install no scope paths. Resolution is covered by <c>AclScopePathResolverTests</c> and the
/// artifact-to-walk path by <c>DeploymentArtifactScopePathTests</c>.
/// </summary>
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopes =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
[Fact]
public void Before_any_artifact_the_node_authenticates_nothing()
{
@@ -30,8 +38,8 @@ public sealed class DataPlaneAuthorityStateTests
{
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-2", [], []).GenerationId.ShouldBe(2);
sut.Apply(null, "rev-1", [], [], NoScopes).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-2", [], [], NoScopes).GenerationId.ShouldBe(2);
}
[Fact]
@@ -41,8 +49,8 @@ public sealed class DataPlaneAuthorityStateTests
// generation there would invalidate every cached authorization decision for no reason.
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-1", [], [], NoScopes).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-1", [], [], NoScopes).GenerationId.ShouldBe(1);
}
[Fact]
@@ -56,7 +64,7 @@ public sealed class DataPlaneAuthorityStateTests
var sut = new DataPlaneAuthorityState();
var generations = new[] { "ffff", "cccc", "aaaa", "0001" }
.Select(rev => sut.Apply(null, rev, [], []).GenerationId)
.Select(rev => sut.Apply(null, rev, [], [], NoScopes).GenerationId)
.ToArray();
generations.ShouldBe([1, 2, 3, 4]);
@@ -66,7 +74,7 @@ public sealed class DataPlaneAuthorityStateTests
public void A_usable_key_is_found_by_id()
{
var sut = new DataPlaneAuthorityState();
sut.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
sut.Apply("cluster-a", "rev-1", [Key("abc", "p")], [], NoScopes);
sut.Current.FindUsableKey("abc", Now)!.Principal.ShouldBe("p");
}
@@ -77,7 +85,7 @@ public sealed class DataPlaneAuthorityStateTests
// Key ids are minted lowercase hex, but an operator copying one out of the AdminUI can upper-case
// it. A case-sensitive miss would read to them as "the key was revoked".
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [Key("abc", "p")], []);
sut.Apply(null, "rev-1", [Key("abc", "p")], [], NoScopes);
sut.Current.FindUsableKey("ABC", Now).ShouldNotBeNull();
}
@@ -88,7 +96,7 @@ public sealed class DataPlaneAuthorityStateTests
var revoked = Key("abc", "p");
revoked.Enabled = false;
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [revoked], []);
sut.Apply(null, "rev-1", [revoked], [], NoScopes);
sut.Current.FindUsableKey("abc", Now).ShouldBeNull();
}
@@ -101,7 +109,7 @@ public sealed class DataPlaneAuthorityStateTests
var expiring = Key("abc", "p");
expiring.ExpiresUtc = Now;
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [expiring], []);
sut.Apply(null, "rev-1", [expiring], [], NoScopes);
sut.Current.FindUsableKey("abc", Now).ShouldBeNull("expiry is inclusive — at the instant it expires it is gone");
sut.Current.FindUsableKey("abc", Now.AddSeconds(-1)).ShouldNotBeNull();
@@ -115,7 +123,7 @@ public sealed class DataPlaneAuthorityStateTests
[
Acl("acl-1", "alice", NodePermissions.Read),
Acl("acl-2", "bob", NodePermissions.WriteOperate),
]);
], NoScopes);
sut.Current.GrantsFor("alice").ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-1");
sut.Current.GrantsFor("nobody").ShouldBeEmpty();
@@ -129,7 +137,7 @@ public sealed class DataPlaneAuthorityStateTests
[
Acl("acl-1", "alice", NodePermissions.Read),
Acl("acl-2", "alice", NodePermissions.WriteOperate),
]);
], NoScopes);
sut.Current.GrantsFor("alice").Count.ShouldBe(2);
}
@@ -140,8 +148,8 @@ public sealed class DataPlaneAuthorityStateTests
// Merging would make revocation impossible: a key dropped from the artifact would live on in the
// node's memory forever.
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [Key("old", "p")], []);
sut.Apply(null, "rev-2", [Key("new", "p")], []);
sut.Apply(null, "rev-1", [Key("old", "p")], [], NoScopes);
sut.Apply(null, "rev-2", [Key("new", "p")], [], NoScopes);
sut.Current.FindUsableKey("old", Now).ShouldBeNull();
sut.Current.FindUsableKey("new", Now).ShouldNotBeNull();
@@ -154,7 +162,7 @@ public sealed class DataPlaneAuthorityStateTests
// artifact — where refusing to build the snapshot would leave the node authenticating nothing.
var sut = new DataPlaneAuthorityState();
Should.NotThrow(() => sut.Apply(null, "rev-1", [Key("dup", "a"), Key("dup", "b")], []));
Should.NotThrow(() => sut.Apply(null, "rev-1", [Key("dup", "a"), Key("dup", "b")], [], NoScopes));
sut.Current.FindUsableKey("dup", Now).ShouldNotBeNull();
}
@@ -164,7 +172,7 @@ public sealed class DataPlaneAuthorityStateTests
// The node manager's only source of cluster identity; NodeScope.ClusterId is required and the
// evaluator fails closed when it disagrees with the session's.
var sut = new DataPlaneAuthorityState();
sut.Apply("cluster-a", "rev-1", [], []);
sut.Apply("cluster-a", "rev-1", [], [], NoScopes);
sut.Current.ClusterId.ShouldBe("cluster-a");
}
@@ -7,17 +7,19 @@ using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// Core-011 regression coverage for <see cref="PermissionTrieBuilder.Build"/>'s
/// <c>Descend</c> helper:
/// Core-011 regression coverage for <see cref="PermissionTrieBuilder.Build"/>'s row placement:
/// <list type="bullet">
/// <item>With a <c>scopePaths</c> lookup the row must land at the correct multi-level
/// trie node — a deep <see cref="NodeAclScopeKind.UnsLine"/> grant must be visible
/// ONLY when the requested scope walks the same namespace/area/line chain.</item>
/// <item>Without a <c>scopePaths</c> entry the row falls back to a direct child of
/// the namespace root keyed on the row's <c>ScopeId</c>. The builder must surface
/// this fallback (warning callback) so callers know a grant was placed where the
/// walker can't reach it for production hierarchies — silently dropping the grant
/// is the Core-011 production hazard.</item>
/// <item>A row whose scope resolves must land at the correct multi-level trie node — a deep
/// <see cref="NodeAclScopeKind.UnsLine"/> grant is visible ONLY when the requested scope
/// walks the same realm/area/line chain.</item>
/// <item>A row whose scope does NOT resolve must be <b>dropped with a diagnostic</b>. Before
/// #520 stage 3 it was planted as a direct child of the trie root keyed on <c>ScopeId</c>,
/// a position level 1 of the walk (the realm) can never reach — so the grant looked placed
/// and could never match. Silently misplacing the grant is the Core-011 production
/// hazard.</item>
/// <item>A scope resolving to several paths (a raw tag projected into N equipments) must plant
/// the grant at <b>all</b> of them, or the grant is bypassable through the NodeId it
/// missed.</item>
/// </list>
/// </summary>
[Trait("Category", "Unit")]
@@ -35,78 +37,150 @@ public sealed class PermissionTrieBuilderTests
PermissionFlags = flags,
};
private static NodeScope EquipmentTag(string cluster, string ns, string area, string line, string equip, string tag) =>
new()
/// <summary>One scope resolving to one path.</summary>
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths(
params (NodeAclScopeKind Kind, string ScopeId, string[] Segments)[] entries) =>
entries.ToDictionary(
e => new AclScopeRef(e.Kind, e.ScopeId),
e => (IReadOnlyList<NodeAclPath>)new[] { new NodeAclPath(e.Segments) });
/// <summary>A UNS-realm trie path: <c>uns / …segments…</c>.</summary>
private static string[] UnsPath(params string[] segments) => [NodeAclRealms.Uns, .. segments];
/// <summary>A raw-realm trie path: <c>raw / …segments…</c>.</summary>
private static string[] RawPath(params string[] segments) => [NodeAclRealms.Raw, .. segments];
[Fact]
public void Build_Path_Not_Starting_At_A_Realm_Is_Rejected_Not_Planted()
{
// Level 1 of the walk is always a realm token, so each of these addresses a node CollectMatches
// can never reach. Without this guard the grant would be planted, look present in the trie, and
// silently never match — the same failure mode as the deleted root-child fallback, just relocated
// into the scope-path producer where nothing else cross-checks it.
string[][] malformed =
[
[], // empty path
["area-1", "line-1"], // producer forgot the realm segment
["Raw", "area-1"], // realm token with the wrong case
["https://zb.com/otopcua/raw", "area-1"], // URI where the short token belongs
];
foreach (var segments in malformed)
{
ClusterId = cluster,
NamespaceId = ns,
UnsAreaId = area,
UnsLineId = line,
EquipmentId = equip,
TagId = tag,
Kind = NodeHierarchyKind.Equipment,
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var rows = new[] { Row("cn=a", NodeAclScopeKind.UnsLine, "line-1", NodePermissions.Read) };
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.UnsLine, "line-1")] = [new NodeAclPath(segments)],
};
var label = segments.Length == 0 ? "(empty)" : string.Join('/', segments);
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.ShouldHaveSingleItem(label).Reason
.ShouldBe(PermissionTrieBuildDiagnosticReason.InvalidScopePath, label);
trie.Root.Children.ShouldBeEmpty(label);
}
}
[Fact]
public void Build_Plants_The_Valid_Paths_Of_A_Scope_Even_When_A_Sibling_Path_Is_Malformed()
{
// The positive control for the theory above: a guard that rejected every path would satisfy it.
// A multi-path scope must keep the reachable placements and drop only the broken one.
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var rows = new[] { Row("cn=a", NodeAclScopeKind.Tag, "tag-1", NodePermissions.Read) };
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.Tag, "tag-1")] =
[
new NodeAclPath(RawPath("Drv", "dev1", "Speed")),
new NodeAclPath(["area-1", "line-1"]),
],
};
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.ShouldHaveSingleItem().Reason
.ShouldBe(PermissionTrieBuildDiagnosticReason.InvalidScopePath);
trie.CollectMatches(
new NodeScope
{
ClusterId = "c1",
Kind = NodeHierarchyKind.Raw,
Segments = ["Drv", "dev1", "Speed"],
},
["cn=a"]).ShouldHaveSingleItem();
}
/// <summary>A node in the UNS realm addressed by its path below the realm segment.</summary>
private static NodeScope UnsScope(string cluster, params string[] segments) =>
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Uns, Segments = segments };
/// <summary>A node in the raw realm addressed by its path below the realm segment.</summary>
private static NodeScope RawScope(string cluster, params string[] segments) =>
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Raw, Segments = segments };
/// <summary>Verifies that Build with ScopePaths places UnsLine row at correct multi-level node.</summary>
[Fact]
public void Build_With_ScopePaths_Places_UnsLine_Row_At_Correct_Multi_Level_Node()
{
// Scope path mirrors the production hierarchy: namespace → area → line.
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["line-42"] = new(new[] { "ns", "area-1", "line-42" }),
};
// Scope path mirrors the production hierarchy: realm → area → line.
var paths = ScopePaths(
(NodeAclScopeKind.UnsLine, "line-42", UnsPath("area-1", "line-42")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
// Walk through the same chain — the grant must be reachable.
var matchOnLine = trie.CollectMatches(
EquipmentTag("c1", "ns", "area-1", "line-42", "eq-A", "tag-A"),
UnsScope("c1", "area-1", "line-42", "eq-A", "tag-A"),
["cn=ops"]);
matchOnLine.Count.ShouldBe(1, "row must land at the correct multi-level trie node");
// A different line under the same area must not pick up the grant.
var matchOnOtherLine = trie.CollectMatches(
EquipmentTag("c1", "ns", "area-1", "line-99", "eq-A", "tag-A"),
UnsScope("c1", "area-1", "line-99", "eq-A", "tag-A"),
["cn=ops"]);
matchOnOtherLine.ShouldBeEmpty(
"grant anchored at line-42 must not leak to sibling line-99 under the same area");
}
/// <summary>Verifies that Build without ScopePaths falls back to root child for deterministic tests.</summary>
/// <summary>
/// Replaces <c>Build_Without_ScopePaths_Falls_Back_To_Root_Child_For_Tests</c>: the null-scopePaths
/// "deterministic test mode" is deleted. An unresolvable sub-cluster row is now dropped entirely —
/// it must NOT appear as a root child keyed on its <c>ScopeId</c>, because level 1 of the walk is
/// the realm and nothing can ever reach such a node.
/// </summary>
[Fact]
public void Build_Without_ScopePaths_Falls_Back_To_Root_Child_For_Tests()
public void Build_Unresolvable_Row_Is_Dropped_Not_Planted_At_Root_Child()
{
// Fallback path — deterministic tests pass without a scope-path lookup. The row
// is placed as a direct child of the trie root keyed by ScopeId.
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
var trie = PermissionTrieBuilder.Build(
"c1", 1, rows, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
// Root has one child — "line-42".
trie.Root.Children.ShouldContainKey("line-42");
var node = trie.Root.Children["line-42"];
node.Grants.Count.ShouldBe(1);
trie.Root.Children.ShouldNotContainKey("line-42",
"the retired fallback planted the row here, where the realm-first walk can never reach it");
trie.Root.Children.ShouldBeEmpty();
trie.Root.Grants.ShouldBeEmpty("an unresolvable row must not become a cluster-wide grant either");
}
/// <summary>
/// Core-011 regression: when a sub-cluster row's ScopeId is not in the supplied
/// <c>scopePaths</c>, the fallback diagnostic callback must fire so the caller can
/// surface a warning. Silently dropping the grant under the wrong trie level is the
/// production hazard the finding flagged.
/// Core-011 regression: when a sub-cluster row's scope is not in the supplied <c>scopePaths</c>,
/// the diagnostic callback must fire so the caller can surface a warning. Silently dropping the
/// grant is the production hazard the finding flagged.
/// </summary>
[Fact]
public void Build_Missing_ScopePath_Entry_Invokes_Diagnostic_Callback()
{
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["line-known"] = new(new[] { "ns", "area-1", "line-known" }),
};
var paths = ScopePaths(
(NodeAclScopeKind.UnsLine, "line-known", UnsPath("area-1", "line-known")));
// Row references a line that is NOT in the path lookup.
var orphan = Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read);
var rows = new[]
{
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read),
orphan,
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-known", NodePermissions.Read),
};
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
@@ -114,45 +188,225 @@ public sealed class PermissionTrieBuilderTests
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.Count.ShouldBe(1, "exactly one row had no matching scope-path entry");
diagnostics[0].NodeAclId.ShouldBe(orphan.NodeAclId);
diagnostics[0].ScopeId.ShouldBe("line-orphan");
diagnostics[0].ScopeKind.ShouldBe(NodeAclScopeKind.UnsLine);
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.MissingScopePath);
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
trie.CollectMatches(UnsScope("c1", "area-1", "line-known", "eq", "tag"), ["cn=ops"])
.Count.ShouldBe(1, "the resolvable row is still placed");
}
/// <summary>Verifies that no diagnostic is emitted when all sub-cluster rows have ScopePaths.</summary>
/// <summary>
/// The scope-path lookup is keyed on <c>(Kind, ScopeId)</c>, not the bare id: an entry filed under
/// one kind must not resolve a row of another kind that happens to share the id. <c>ScopeId</c> is
/// an unconstrained string, so id collisions across entity types are possible and resolving one
/// through the other is a silent wrong-node authorization.
/// </summary>
[Fact]
public void Build_No_Diagnostic_When_All_Sub_Cluster_Rows_Have_ScopePaths()
public void Build_ScopePath_Lookup_Is_Keyed_On_Kind_As_Well_As_Id()
{
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["line-A"] = new(new[] { "ns", "area-1", "line-A" }),
["line-B"] = new(new[] { "ns", "area-1", "line-B" }),
};
var paths = ScopePaths(
(NodeAclScopeKind.UnsLine, "shared-id", UnsPath("area-1", "line-1")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "shared-id", NodePermissions.Read) };
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.Count.ShouldBe(1);
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
trie.CollectMatches(UnsScope("c1", "area-1", "line-1", "eq", "tag"), ["cn=ops"])
.ShouldBeEmpty("an Equipment row must not borrow a UnsLine entry's path");
}
/// <summary>
/// Verifies that a diagnostic fires for the unresolvable row and for that row only. The negative
/// half on its own ("no diagnostics were emitted") passes for a <c>Build</c> that never emits any,
/// so the positive control lives in the same build rather than in a separate test.
/// </summary>
[Fact]
public void Build_Diagnostic_Fires_For_The_Unresolvable_Row_Only()
{
var paths = ScopePaths(
(NodeAclScopeKind.UnsLine, "line-A", UnsPath("area-1", "line-A")),
(NodeAclScopeKind.UnsLine, "line-B", UnsPath("area-1", "line-B")));
var orphan = Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read);
var rows = new[]
{
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read), // cluster-level — no descent
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read), // cluster-level — never descends
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-A", NodePermissions.Read),
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-B", NodePermissions.Read),
orphan, // positive control
};
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.ShouldBeEmpty("no rows are missing a scope-path entry");
diagnostics.Count.ShouldBe(1, "only the orphan row is unresolvable");
diagnostics[0].NodeAclId.ShouldBe(orphan.NodeAclId,
"the cluster row and the two resolvable line rows must not emit a diagnostic");
// …and the rows that did resolve are genuinely placed, so "one diagnostic" is not being reached
// by dropping everything.
trie.CollectMatches(UnsScope("c1", "area-1", "line-A", "eq", "tag"), ["cn=ops"])
.Count.ShouldBe(2, "the cluster grant plus the line-A grant");
trie.CollectMatches(UnsScope("c1", "area-1", "line-B", "eq", "tag"), ["cn=ops"])
.Count.ShouldBe(2, "the cluster grant plus the line-B grant");
}
/// <summary>Verifies that diagnostic callback is optional when ScopePaths is null.</summary>
[Fact]
public void Build_Diagnostic_Callback_Optional_When_ScopePaths_Null()
/// <summary>
/// Replaces <c>Build_Diagnostic_Callback_Optional_When_ScopePaths_Null</c> — <c>scopePaths</c> is
/// required now, so the mode that test described no longer exists. What still needs pinning is the
/// other authoring mistake: a sub-cluster row carrying no <c>ScopeId</c> names nothing, and used to
/// vanish with no signal at all.
/// </summary>
[Theory]
[InlineData(null)]
[InlineData("")]
public void Build_Sub_Cluster_Row_Without_ScopeId_Emits_MissingScopeId(string? scopeId)
{
// No diagnostics callback should fire when scopePaths itself is null — that's the
// "deterministic-test fallback" mode, not a production drop.
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
var row = Row("cn=ops", NodeAclScopeKind.UnsLine, scopeId, NodePermissions.Read);
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
PermissionTrieBuilder.Build("c1", 1, rows, scopePaths: null, diagnostic: diagnostics.Add);
var trie = PermissionTrieBuilder.Build(
"c1", 1, [row], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(), diagnostics.Add);
diagnostics.ShouldBeEmpty(
"scopePaths=null is the explicit test-fallback mode and must not emit per-row warnings");
diagnostics.Count.ShouldBe(1);
diagnostics[0].NodeAclId.ShouldBe(row.NodeAclId);
diagnostics[0].ScopeId.ShouldBeNull("the row had no scope id to report");
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.MissingScopeId);
trie.Root.Grants.ShouldBeEmpty("a scope-less sub-cluster row must not fall through to the cluster root");
}
/// <summary>
/// A <see cref="NodeAclScopeKind.Cluster"/> row never consults <c>scopePaths</c> and never emits a
/// diagnostic, even though it carries a null <c>ScopeId</c> — that is the one kind for which a null
/// id is correct.
/// </summary>
[Fact]
public void Build_Cluster_Row_With_Null_ScopeId_Is_Placed_Without_Diagnostic()
{
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var trie = PermissionTrieBuilder.Build(
"c1", 1, rows, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(), diagnostics.Add);
diagnostics.ShouldBeEmpty();
trie.Root.Grants.Count.ShouldBe(1);
}
/// <summary>
/// A scope that resolves to several paths — a raw tag plus its UNS projections — plants the grant
/// at every one of them. A write routes to the same driver reference through either NodeId, so a
/// grant covering only one path would be bypassable through the other.
/// </summary>
[Fact]
public void Build_Multi_Path_Scope_Plants_The_Grant_At_Every_Path()
{
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.Tag, "tag-1")] = new[]
{
new NodeAclPath(RawPath("Plant", "Modbus", "dev1", "Speed")),
new NodeAclPath(UnsPath("area-1", "line-1", "eq-A", "Speed")),
new NodeAclPath(UnsPath("area-1", "line-1", "eq-B", "MotorSpeed")),
},
};
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Tag, "tag-1", NodePermissions.WriteOperate) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"])
.Count.ShouldBe(1, "the raw NodeId");
trie.CollectMatches(UnsScope("c1", "area-1", "line-1", "eq-A", "Speed"), ["cn=ops"])
.Count.ShouldBe(1, "the first UNS projection");
trie.CollectMatches(UnsScope("c1", "area-1", "line-1", "eq-B", "MotorSpeed"), ["cn=ops"])
.Count.ShouldBe(1, "the second UNS projection, under its own effective name");
trie.CollectMatches(UnsScope("c1", "area-1", "line-1", "eq-B", "Speed"), ["cn=ops"])
.ShouldBeEmpty("eq-B projects the tag as MotorSpeed; the eq-A name must not match there");
}
/// <summary>
/// An empty path list for a resolved key is treated the same as an absent key — dropped with an
/// <see cref="PermissionTrieBuildDiagnosticReason.UnresolvedScope"/> diagnostic rather than
/// silently planting the grant at the cluster root, which is where a zero-segment walk would leave
/// it.
/// </summary>
[Fact]
public void Build_Empty_Path_List_Is_Treated_As_Unresolved()
{
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.Equipment, "eq-A")] = Array.Empty<NodeAclPath>(),
};
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.Read) };
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.Count.ShouldBe(1);
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
trie.Root.Grants.ShouldBeEmpty("an empty path list must not become a cluster-wide grant");
}
/// <summary>
/// Gitea #533 — the retired <see cref="NodeAclScopeKind.FolderSegment"/> gets its own reason. It
/// used to arrive as <see cref="PermissionTrieBuildDiagnosticReason.UnresolvedScope"/>, which told
/// an operator holding a legacy v2 row to go hunting for a typo in a <c>ScopeId</c> that was never
/// the problem — the kind itself has no v3 entity behind it and can never resolve. The two have
/// different remedies, so they need different reasons.
/// </summary>
[Fact]
public void Build_Retired_FolderSegment_Row_Emits_RetiredScopeKind()
{
var row = Row("cn=ops", NodeAclScopeKind.FolderSegment, "seg-1", NodePermissions.Read);
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var trie = PermissionTrieBuilder.Build(
"c1", 1, [row], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(), diagnostics.Add);
diagnostics.Count.ShouldBe(1);
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.RetiredScopeKind);
diagnostics[0].ScopeId.ShouldBe("seg-1", "the id is still reported, so the operator can find the row");
trie.Root.Grants.ShouldBeEmpty("a retired-kind row must not fall through to the cluster root");
}
/// <summary>
/// A genuinely dangling id must keep reporting <see cref="PermissionTrieBuildDiagnosticReason.UnresolvedScope"/>.
/// Without this, moving <c>FolderSegment</c> to its own reason could be "achieved" by relabelling
/// every drop, and the split would carry no information.
/// </summary>
[Fact]
public void Build_A_Live_Kind_With_A_Dangling_Id_Still_Emits_UnresolvedScope()
{
var row = Row("cn=ops", NodeAclScopeKind.RawFolder, "RF-gone", NodePermissions.Read);
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
PermissionTrieBuilder.Build(
"c1", 1, [row], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(), diagnostics.Add);
diagnostics.ShouldHaveSingleItem().Reason
.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
}
/// <summary>
/// Gitea #533 — the identifier rule reaches the builder's own <c>ClusterId</c> filter, which has
/// always been <c>OrdinalIgnoreCase</c>. Pinned because the two spellings genuinely occur: a
/// ClusterId arrives lower-cased through <c>OTOPCUA_ROLES</c> and verbatim through
/// <c>Cluster:Roles</c>.
/// </summary>
[Fact]
public void Build_Matches_The_Cluster_Case_Insensitively()
{
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
PermissionTrieBuilder.Build(
"C1", 1, rows, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>())
.Root.Grants.ShouldHaveSingleItem();
PermissionTrieBuilder.Build(
"c2", 1, rows, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>())
.Root.Grants.ShouldBeEmpty("a DIFFERENT cluster must still be excluded");
}
}
@@ -6,9 +6,18 @@ using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// Coverage for <see cref="PermissionTrie.CollectMatches"/> — the walk itself. Post-#520-stage-3 the
/// walk is <c>cluster → realm → …variable-length node path…</c>, so every non-cluster test here
/// supplies a real <c>scopePaths</c> entry whose path starts with a realm segment and drives a
/// <see cref="NodeScope.Segments"/> chain through it.
/// </summary>
[Trait("Category", "Unit")]
public sealed class PermissionTrieTests
{
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags, string clusterId = "c1") =>
new()
{
@@ -21,27 +30,36 @@ public sealed class PermissionTrieTests
PermissionFlags = flags,
};
private static NodeScope EquipmentTag(string cluster, string ns, string area, string line, string equip, string tag) =>
new()
{
ClusterId = cluster,
NamespaceId = ns,
UnsAreaId = area,
UnsLineId = line,
EquipmentId = equip,
TagId = tag,
Kind = NodeHierarchyKind.Equipment,
};
/// <summary>One scope resolving to one path.</summary>
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths(
params (NodeAclScopeKind Kind, string ScopeId, string[] Segments)[] entries) =>
entries.ToDictionary(
e => new AclScopeRef(e.Kind, e.ScopeId),
e => (IReadOnlyList<NodeAclPath>)new[] { new NodeAclPath(e.Segments) });
/// <summary>A UNS-realm trie path: <c>uns / …segments…</c>.</summary>
private static string[] UnsPath(params string[] segments) => [NodeAclRealms.Uns, .. segments];
/// <summary>A raw-realm trie path: <c>raw / …segments…</c>.</summary>
private static string[] RawPath(params string[] segments) => [NodeAclRealms.Raw, .. segments];
/// <summary>A node in the UNS realm addressed by its path below the realm segment.</summary>
private static NodeScope UnsScope(string cluster, params string[] segments) =>
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Uns, Segments = segments };
/// <summary>A node in the raw realm addressed by its path below the realm segment.</summary>
private static NodeScope RawScope(string cluster, params string[] segments) =>
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Raw, Segments = segments };
/// <summary>Verifies cluster-level grant cascades to every tag.</summary>
[Fact]
public void ClusterLevelGrant_Cascades_ToEveryTag()
{
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, scopeId: null, NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
var trie = PermissionTrieBuilder.Build("c1", 1, rows, NoScopePaths);
var matches = trie.CollectMatches(
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
UnsScope("c1", "area1", "line1", "eq1", "tag1"),
["cn=ops"]);
matches.Count.ShouldBe(1);
@@ -53,38 +71,44 @@ public sealed class PermissionTrieTests
[Fact]
public void EquipmentScope_DoesNotLeak_ToSibling()
{
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["eq-A"] = new(new[] { "ns", "area1", "line1", "eq-A" }),
};
var paths = ScopePaths(
(NodeAclScopeKind.Equipment, "eq-A", UnsPath("area1", "line1", "eq-A")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var matchA = trie.CollectMatches(EquipmentTag("c1", "ns", "area1", "line1", "eq-A", "tag1"), ["cn=ops"]);
var matchB = trie.CollectMatches(EquipmentTag("c1", "ns", "area1", "line1", "eq-B", "tag1"), ["cn=ops"]);
var matchA = trie.CollectMatches(UnsScope("c1", "area1", "line1", "eq-A", "tag1"), ["cn=ops"]);
var matchB = trie.CollectMatches(UnsScope("c1", "area1", "line1", "eq-B", "tag1"), ["cn=ops"]);
matchA.Count.ShouldBe(1);
matchB.ShouldBeEmpty("grant at eq-A must not apply to sibling eq-B");
}
/// <summary>Verifies multiple groups union their permission flags.</summary>
/// <summary>
/// Verifies that every principal-matching grant is collected once, with its own flags left
/// untouched. <see cref="PermissionTrie.CollectMatches"/> deliberately does NOT combine the flag
/// bits — the OR lives in <c>TriePermissionEvaluator.Authorize</c> and is pinned there by
/// <c>TriePermissionEvaluatorTests.Union_Across_Levels_Ors_Flags_From_Every_Match</c>. (Renamed
/// from <c>MultiGroup_Union_OrsPermissionFlags</c>, which OR-ed the flags in the test body and so
/// asserted nothing about production behaviour.)
/// </summary>
[Fact]
public void MultiGroup_Union_OrsPermissionFlags()
public void MultiGroup_Matches_Are_Collected_PerGrant_Without_Combining()
{
var rows = new[]
{
Row("cn=readers", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
Row("cn=writers", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
};
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
var trie = PermissionTrieBuilder.Build("c1", 1, rows, NoScopePaths);
var matches = trie.CollectMatches(
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
UnsScope("c1", "area1", "line1", "eq1", "tag1"),
["cn=readers", "cn=writers"]);
matches.Count.ShouldBe(2);
var combined = matches.Aggregate(NodePermissions.None, (acc, m) => acc | m.PermissionFlags);
combined.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
matches.Select(m => m.Principal).ShouldBe(["cn=readers", "cn=writers"], ignoreOrder: true);
matches.Single(m => m.Principal == "cn=readers").PermissionFlags.ShouldBe(NodePermissions.Read);
matches.Single(m => m.Principal == "cn=writers").PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
}
/// <summary>Verifies no matching group returns empty.</summary>
@@ -92,57 +116,52 @@ public sealed class PermissionTrieTests
public void NoMatchingGroup_ReturnsEmpty()
{
var rows = new[] { Row("cn=different", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
var trie = PermissionTrieBuilder.Build("c1", 1, rows, NoScopePaths);
var matches = trie.CollectMatches(
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
UnsScope("c1", "area1", "line1", "eq1", "tag1"),
["cn=ops"]);
matches.ShouldBeEmpty();
}
/// <summary>
/// Galaxy is now a standard Equipment-kind driver: Galaxy points are ordinary equipment
/// tags, so a grant on a Galaxy equipment resolves via the Equipment walk and must not leak
/// to a sibling Galaxy equipment.
/// Galaxy is a standard Equipment-kind driver: Galaxy points are ordinary equipment tags, so a
/// grant on a Galaxy equipment resolves via the UNS walk and must not leak to a sibling Galaxy
/// equipment.
/// </summary>
[Fact]
public void Galaxy_EquipmentScope_Grant_DoesNotLeak_To_Sibling_Equipment()
{
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["gal-eq-A"] = new(new[] { "ns-gal", "area1", "line1", "gal-eq-A" }),
};
var paths = ScopePaths(
(NodeAclScopeKind.Equipment, "gal-eq-A", UnsPath("area1", "line1", "gal-eq-A")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "gal-eq-A", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var matchA = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq-A", "tag1"), ["cn=ops"]);
var matchB = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq-B", "tag1"), ["cn=ops"]);
var matchA = trie.CollectMatches(UnsScope("c1", "area1", "line1", "gal-eq-A", "tag1"), ["cn=ops"]);
var matchB = trie.CollectMatches(UnsScope("c1", "area1", "line1", "gal-eq-B", "tag1"), ["cn=ops"]);
matchA.Count.ShouldBe(1);
matchB.ShouldBeEmpty("grant at gal-eq-A must not apply to sibling gal-eq-B");
}
/// <summary>
/// A grant on a Galaxy equipment tag is reported with its true Equipment-walk structural
/// scope (Equipment), not a Galaxy-specific scope — Galaxy now flows through the same
/// Equipment permission walk as every other equipment tag.
/// A grant on a Galaxy equipment tag is reported with the authored row's Equipment scope kind —
/// Galaxy flows through the same UNS permission walk as every other equipment tag.
/// </summary>
[Fact]
public void Galaxy_EquipmentScope_Grant_Reports_Equipment_Scope()
{
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["gal-eq"] = new(new[] { "ns-gal", "area1", "line1", "gal-eq" }),
};
var paths = ScopePaths(
(NodeAclScopeKind.Equipment, "gal-eq", UnsPath("area1", "line1", "gal-eq")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "gal-eq", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var matches = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq", "tag1"), ["cn=ops"]);
var matches = trie.CollectMatches(UnsScope("c1", "area1", "line1", "gal-eq", "tag1"), ["cn=ops"]);
matches.Count.ShouldBe(1);
matches[0].Scope.ShouldBe(NodeAclScopeKind.Equipment,
"Galaxy equipment grants resolve via the Equipment walk and report Equipment scope");
"Galaxy equipment grants resolve via the UNS walk and report the row's Equipment scope");
}
/// <summary>Verifies cross-cluster grant does not leak.</summary>
@@ -150,16 +169,21 @@ public sealed class PermissionTrieTests
public void CrossCluster_Grant_DoesNotLeak()
{
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read, clusterId: "c-other") };
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
var trie = PermissionTrieBuilder.Build("c1", 1, rows, NoScopePaths);
var matches = trie.CollectMatches(
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
UnsScope("c1", "area1", "line1", "eq1", "tag1"),
["cn=ops"]);
matches.ShouldBeEmpty("rows for cluster c-other must not land in c1's trie");
}
/// <summary>Verifies build is idempotent.</summary>
/// <summary>
/// Verifies build is idempotent, against an <b>independent</b> expectation rather than by
/// comparing one build's output to the other's. The previous form asserted
/// <c>trie1.Count.ShouldBe(trie2.Count)</c>, which a <c>Build</c> that dropped every row passed as
/// <c>0 == 0</c>; the absolute counts below go red the moment placement regresses.
/// </summary>
[Fact]
public void Build_IsIdempotent()
{
@@ -167,13 +191,187 @@ public sealed class PermissionTrieTests
{
Row("cn=a", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
Row("cn=b", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
Row("cn=a", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.WriteOperate),
};
var paths = ScopePaths(
(NodeAclScopeKind.Equipment, "eq-A", UnsPath("area1", "line1", "eq-A")));
var scope = UnsScope("c1", "area1", "line1", "eq-A", "tag1");
var trie1 = PermissionTrieBuilder.Build("c1", 1, rows);
var trie2 = PermissionTrieBuilder.Build("c1", 1, rows);
var trie1 = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var trie2 = PermissionTrieBuilder.Build("c1", 1, rows, paths);
trie1.Root.Grants.Count.ShouldBe(trie2.Root.Grants.Count);
trie1.ClusterId.ShouldBe(trie2.ClusterId);
trie1.GenerationId.ShouldBe(trie2.GenerationId);
foreach (var trie in new[] { trie1, trie2 })
{
trie.ClusterId.ShouldBe("c1");
trie.GenerationId.ShouldBe(1);
trie.Root.Grants.Count.ShouldBe(2, "both cluster rows land at the root");
trie.CollectMatches(scope, ["cn=a"]).Count.ShouldBe(2, "one cluster grant + the eq-A grant");
trie.CollectMatches(scope, ["cn=b"]).Count.ShouldBe(1, "cn=b holds only the cluster grant");
}
}
/// <summary>
/// A grant planted at a raw container matches a tag nested arbitrarily deep beneath it — two
/// nested folders and two nested tag groups here — and does <b>not</b> match a sibling device.
/// The sibling leg is the negative control: without it a walk that ignored
/// <see cref="NodeScope.Segments"/> entirely would still pass the positive leg.
/// </summary>
[Fact]
public void RawDevice_Grant_Matches_Deeply_Nested_Tag_And_Not_A_Sibling_Device()
{
var paths = ScopePaths(
(NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "CellA", "Modbus", "dev1")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
// raw / Plant / CellA / Modbus / dev1 / Motors / Drive / Speed — 7 segments below the realm.
var beneath = trie.CollectMatches(
RawScope("c1", "Plant", "CellA", "Modbus", "dev1", "Motors", "Drive", "Speed"),
["cn=ops"]);
var sibling = trie.CollectMatches(
RawScope("c1", "Plant", "CellA", "Modbus", "dev2", "Motors", "Drive", "Speed"),
["cn=ops"]);
beneath.Count.ShouldBe(1, "a container grant cascades to every node beneath it, at any depth");
beneath[0].Scope.ShouldBe(NodeAclScopeKind.RawDevice);
sibling.ShouldBeEmpty("grant at dev1 must not reach sibling device dev2");
}
/// <summary>
/// Regression guard for the <c>StringComparer.Ordinal</c> children comparer (#520 stage 3): a
/// grant authored on <c>Speed</c> must not match a scope for <c>speed</c>. Raw sibling names are
/// unique only under an ordinal comparer, so both spellings can exist under one device and the
/// previous <c>OrdinalIgnoreCase</c> map silently granted one through the other. The positive leg
/// is asserted alongside so the negative cannot pass by the walk simply never matching.
/// </summary>
[Fact]
public void Segment_Matching_Is_Case_Sensitive()
{
var paths = ScopePaths(
(NodeAclScopeKind.Tag, "tag-speed", RawPath("Plant", "Modbus", "dev1", "Speed")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Tag, "tag-speed", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var exact = trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"]);
var caseDrifted = trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "speed"), ["cn=ops"]);
exact.Count.ShouldBe(1, "the exact spelling must match, or the negative leg proves nothing");
caseDrifted.ShouldBeEmpty(
"segment matching is case-sensitive ordinal — 'Speed' and 'speed' are distinct raw siblings");
}
/// <summary>
/// Regression guard for the case-sensitive comparer on the <b>container</b> levels too: a grant
/// under device <c>Dev1</c> must not cascade into a scope walking <c>dev1</c>.
/// </summary>
[Fact]
public void Container_Segment_Matching_Is_Case_Sensitive()
{
var paths = ScopePaths(
(NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "Modbus", "Dev1")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "Dev1", "Speed"), ["cn=ops"])
.Count.ShouldBe(1);
trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"])
.ShouldBeEmpty("a case-drifted container segment must not cascade");
}
/// <summary>
/// The realm is a hard gate at level 1: a grant planted in the raw realm must not match a scope in
/// the UNS realm, even when the segments below the realm are identical. This pins that the walk
/// derives its level-1 key from <see cref="NodeScope.Kind"/> via
/// <see cref="NodeAclRealms.SegmentFor(NodeHierarchyKind)"/> rather than skipping the level.
/// </summary>
[Fact]
public void Grant_In_One_Realm_DoesNot_Match_A_Scope_In_The_Other()
{
var paths = ScopePaths(
(NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "Modbus", "dev1")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"])
.Count.ShouldBe(1, "the raw scope must match, or the cross-realm leg proves nothing");
trie.CollectMatches(UnsScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"])
.ShouldBeEmpty("a raw-realm grant must not be reachable from a UNS scope");
}
/// <summary>
/// A <see cref="NodeAclScopeKind.Namespace"/> row planted at the realm segment itself grants every
/// node in that realm and nothing in the other one.
/// </summary>
[Fact]
public void NamespaceScope_Grant_Covers_Its_Realm_Only()
{
var paths = ScopePaths(
(NodeAclScopeKind.Namespace, NodeAclRealms.Raw, [NodeAclRealms.Raw]));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Namespace, NodeAclRealms.Raw, NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var inRaw = trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"]);
var inUns = trie.CollectMatches(UnsScope("c1", "area1", "line1", "eq1", "tag1"), ["cn=ops"]);
inRaw.Count.ShouldBe(1);
inRaw[0].Scope.ShouldBe(NodeAclScopeKind.Namespace);
inUns.ShouldBeEmpty("a raw-namespace grant must not cover the UNS realm");
}
/// <summary>
/// Provenance is the granting <b>row's</b> <see cref="NodeAclScopeKind"/>, not one derived from the
/// structural depth the grant sits at. Two rows of different kinds planted at the same trie node
/// must report their own kinds — under any depth-derived scheme they would report the same one.
/// </summary>
[Fact]
public void MatchedGrant_Reports_The_Rows_Own_ScopeKind_Not_A_Depth_Derived_One()
{
var samePath = RawPath("Plant", "Modbus", "dev1");
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.RawDevice, "dev-1")] = new[] { new NodeAclPath(samePath) },
[new AclScopeRef(NodeAclScopeKind.RawTagGroup, "grp-1")] = new[] { new NodeAclPath(samePath) },
};
var rows = new[]
{
Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read),
Row("cn=ops", NodeAclScopeKind.RawTagGroup, "grp-1", NodePermissions.WriteOperate),
};
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var matches = trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"]);
matches.Count.ShouldBe(2);
matches.Select(m => m.Scope).ShouldBe(
[NodeAclScopeKind.RawDevice, NodeAclScopeKind.RawTagGroup],
ignoreOrder: true,
"each grant carries the scope kind its own row was authored with");
}
/// <summary>
/// A scope addressing the realm itself (no segments) collects the realm-level and cluster-level
/// grants and nothing planted below it.
/// </summary>
[Fact]
public void RealmScope_With_No_Segments_Collects_Only_Cluster_And_Realm_Grants()
{
var paths = ScopePaths(
(NodeAclScopeKind.Namespace, NodeAclRealms.Raw, [NodeAclRealms.Raw]),
(NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "Modbus", "dev1")));
var rows = new[]
{
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Browse),
Row("cn=ops", NodeAclScopeKind.Namespace, NodeAclRealms.Raw, NodePermissions.Read),
Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.WriteOperate),
};
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var matches = trie.CollectMatches(RawScope("c1"), ["cn=ops"]);
matches.Select(m => m.Scope).ShouldBe(
[NodeAclScopeKind.Cluster, NodeAclScopeKind.Namespace],
ignoreOrder: true,
"the device grant sits below the realm and must not be collected at the realm");
}
}
@@ -13,6 +13,9 @@ public sealed class TriePermissionEvaluatorTests
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
private readonly FakeTimeProvider _time = new();
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
private sealed class FakeTimeProvider : TimeProvider
{
/// <summary>Gets or sets the current UTC time.</summary>
@@ -46,22 +49,42 @@ public sealed class TriePermissionEvaluatorTests
MembershipVersion = 1,
};
/// <summary>One scope resolving to one path.</summary>
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths(
params (NodeAclScopeKind Kind, string ScopeId, string[] Segments)[] entries) =>
entries.ToDictionary(
e => new AclScopeRef(e.Kind, e.ScopeId),
e => (IReadOnlyList<NodeAclPath>)new[] { new NodeAclPath(e.Segments) });
/// <summary>A UNS-realm trie path: <c>uns / …segments…</c>.</summary>
private static string[] UnsPath(params string[] segments) => [NodeAclRealms.Uns, .. segments];
/// <summary>A raw-realm trie path: <c>raw / …segments…</c>.</summary>
private static string[] RawPath(params string[] segments) => [NodeAclRealms.Raw, .. segments];
/// <summary>The default UNS node the flag/staleness/generation tests evaluate against.</summary>
private static NodeScope Scope(string cluster = "c1") =>
new()
{
ClusterId = cluster,
NamespaceId = "ns",
UnsAreaId = "area",
UnsLineId = "line",
EquipmentId = "eq",
TagId = "tag",
Kind = NodeHierarchyKind.Equipment,
Kind = NodeHierarchyKind.Uns,
Segments = ["area", "line", "eq", "tag"],
};
private TriePermissionEvaluator MakeEvaluator(NodeAcl[] rows)
/// <summary>A node in the UNS realm addressed by its path below the realm segment.</summary>
private static NodeScope UnsScope(string cluster, params string[] segments) =>
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Uns, Segments = segments };
/// <summary>A node in the raw realm addressed by its path below the realm segment.</summary>
private static NodeScope RawScope(string cluster, params string[] segments) =>
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Raw, Segments = segments };
private TriePermissionEvaluator MakeEvaluator(
NodeAcl[] rows,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>? scopePaths = null)
{
var cache = new PermissionTrieCache();
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows));
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows, scopePaths ?? NoScopePaths));
return new TriePermissionEvaluator(cache, _time);
}
@@ -160,8 +183,8 @@ public sealed class TriePermissionEvaluatorTests
// The evaluator must still honour the session's bound generation 1, not generation 2.
var gen1Row = Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read);
var cache = new PermissionTrieCache();
cache.Install(PermissionTrieBuilder.Build("c1", 1, [gen1Row]));
cache.Install(PermissionTrieBuilder.Build("c1", 2, [])); // gen 2 — grant revoked, now current
cache.Install(PermissionTrieBuilder.Build("c1", 1, [gen1Row], NoScopePaths));
cache.Install(PermissionTrieBuilder.Build("c1", 2, [], NoScopePaths)); // gen 2 — grant revoked, now current
var evaluator = new TriePermissionEvaluator(cache, _time);
var decision = evaluator.Authorize(
@@ -180,7 +203,7 @@ public sealed class TriePermissionEvaluatorTests
// The evaluator must fail closed rather than silently using the current generation.
var gen5Row = Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read);
var cache = new PermissionTrieCache();
cache.Install(PermissionTrieBuilder.Build("c1", 5, [gen5Row]));
cache.Install(PermissionTrieBuilder.Build("c1", 5, [gen5Row], NoScopePaths));
var evaluator = new TriePermissionEvaluator(cache, _time);
// Session bound to generation 1, which was never installed / has been pruned.
@@ -234,4 +257,144 @@ public sealed class TriePermissionEvaluatorTests
TriePermissionEvaluator.MapOperationToPermission(op);
}
}
/// <summary>
/// End-to-end coverage of the sub-cluster walk: an Equipment-scoped grant authorizes a node
/// beneath that equipment, and denies the same operation on a sibling equipment. Every other test
/// in this class uses a cluster-scoped grant, which the walk collects at level 0 before it ever
/// descends — so without this pair nothing here exercises <see cref="NodeScope.Segments"/> at all.
/// </summary>
[Fact]
public void SubCluster_Grant_Allows_Beneath_Its_Equipment_And_Denies_A_Sibling()
{
var evaluator = MakeEvaluator(
[Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.WriteOperate)],
ScopePaths((NodeAclScopeKind.Equipment, "eq-A", UnsPath("area-1", "line-1", "eq-A"))));
var onEquipment = evaluator.Authorize(
Session(["cn=ops"]), OpcUaOperation.WriteOperate,
UnsScope("c1", "area-1", "line-1", "eq-A", "Setpoint"));
var onSibling = evaluator.Authorize(
Session(["cn=ops"]), OpcUaOperation.WriteOperate,
UnsScope("c1", "area-1", "line-1", "eq-B", "Setpoint"));
onEquipment.IsAllowed.ShouldBeTrue("the grant sits on the equipment the scope walks through");
onEquipment.Provenance.Count.ShouldBe(1);
onEquipment.Provenance[0].Scope.ShouldBe(NodeAclScopeKind.Equipment);
onSibling.IsAllowed.ShouldBeFalse("a grant on eq-A must not authorize eq-B");
}
/// <summary>
/// The same walk through the raw realm, over a path deep enough to have nested folders and nested
/// tag groups: a grant on the device authorizes a tag six levels below it, and not the sibling
/// device's tag.
/// </summary>
[Fact]
public void SubCluster_Grant_Walks_A_Deep_Raw_Path()
{
var evaluator = MakeEvaluator(
[Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read)],
ScopePaths((NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "CellA", "Modbus", "dev1"))));
var beneath = evaluator.Authorize(
Session(["cn=ops"]), OpcUaOperation.Read,
RawScope("c1", "Plant", "CellA", "Modbus", "dev1", "Motors", "Drive", "Speed"));
var sibling = evaluator.Authorize(
Session(["cn=ops"]), OpcUaOperation.Read,
RawScope("c1", "Plant", "CellA", "Modbus", "dev2", "Motors", "Drive", "Speed"));
beneath.IsAllowed.ShouldBeTrue();
beneath.Provenance[0].Scope.ShouldBe(NodeAclScopeKind.RawDevice,
"provenance names the row's own kind, not one derived from the depth it was planted at");
sibling.IsAllowed.ShouldBeFalse("the grant is anchored at dev1");
}
/// <summary>
/// The permission OR lives here, in <c>Authorize</c>: flags from grants matched at different trie
/// levels combine into one bitmask. Both legs are asserted with the same session and scope, so
/// neither "use the first match" nor "use the last match" can pass.
/// </summary>
[Fact]
public void Union_Across_Levels_Ors_Flags_From_Every_Match()
{
var evaluator = MakeEvaluator(
[
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.WriteOperate),
],
ScopePaths((NodeAclScopeKind.Equipment, "eq-A", UnsPath("area-1", "line-1", "eq-A"))));
var scope = UnsScope("c1", "area-1", "line-1", "eq-A", "Setpoint");
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, scope)
.IsAllowed.ShouldBeTrue("Read comes from the cluster-level grant");
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.WriteOperate, scope)
.IsAllowed.ShouldBeTrue("WriteOperate comes from the equipment-level grant");
}
/// <summary>
/// The union also spans principals: a session carrying two principals gets the OR of both their
/// grants, and neither principal alone carries both permissions.
/// </summary>
[Fact]
public void Union_Across_Principals_Ors_Flags()
{
NodeAcl[] rows =
[
Row("cn=readers", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
Row("cn=writers", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
];
var evaluator = MakeEvaluator(rows);
evaluator.Authorize(Session(["cn=readers", "cn=writers"]), OpcUaOperation.WriteOperate, Scope())
.IsAllowed.ShouldBeTrue();
evaluator.Authorize(Session(["cn=readers", "cn=writers"]), OpcUaOperation.Read, Scope())
.IsAllowed.ShouldBeTrue();
evaluator.Authorize(Session(["cn=readers"]), OpcUaOperation.WriteOperate, Scope())
.IsAllowed.ShouldBeFalse("cn=readers alone carries no write bit");
}
/// <summary>
/// A grant resolved into the raw realm must not authorize the UNS node of the same name. The realm
/// is level 1 of the walk and is derived from <see cref="NodeScope.Kind"/>, so a scope in the wrong
/// realm never reaches the grant.
/// </summary>
[Fact]
public void Grant_In_The_Raw_Realm_DoesNot_Authorize_A_Uns_Scope()
{
var evaluator = MakeEvaluator(
[Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read)],
ScopePaths((NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "Modbus", "dev1"))));
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read,
RawScope("c1", "Plant", "Modbus", "dev1", "Speed"))
.IsAllowed.ShouldBeTrue("the raw scope must be allowed, or the cross-realm leg proves nothing");
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read,
UnsScope("c1", "Plant", "Modbus", "dev1", "Speed"))
.IsAllowed.ShouldBeFalse("a raw-realm grant must not be reachable from a UNS scope");
}
/// <summary>
/// A case-drifted scope segment must not reach a grant — the trie's children compare with
/// <see cref="StringComparer.Ordinal"/>, matching OPC UA NodeId identity, so <c>Speed</c> and
/// <c>speed</c> are distinct nodes and a grant on one denies the other.
/// </summary>
[Fact]
public void Case_Drifted_Scope_Segment_Is_NotGranted()
{
var evaluator = MakeEvaluator(
[Row("cn=ops", NodeAclScopeKind.Tag, "tag-speed", NodePermissions.Read)],
ScopePaths((NodeAclScopeKind.Tag, "tag-speed", RawPath("Plant", "Modbus", "dev1", "Speed"))));
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read,
RawScope("c1", "Plant", "Modbus", "dev1", "Speed"))
.IsAllowed.ShouldBeTrue();
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read,
RawScope("c1", "Plant", "Modbus", "dev1", "speed"))
.IsAllowed.ShouldBeFalse("segment matching is case-sensitive ordinal");
}
}
@@ -0,0 +1,44 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Gitea #534 — the authored EtherNet/IP port must reach libplctag's <c>gateway</c> attribute.
/// </summary>
/// <remarks>
/// This driver carried the identical defect to AbLegacy, where it was found:
/// <see cref="AbCipTagCreateParams.Port"/> was on the record and consumed by nobody, so the runtime
/// handed libplctag the bare host and libplctag substituted 44818. It survived here precisely because
/// it is invisible here — every AbCip fixture profile binds 44818, so no test this driver has could
/// distinguish "uses the authored port" from "always uses 44818". Worth remembering as a shape: a
/// fixture that only ever exercises the default value cannot falsify a rule about non-default values,
/// however many tests point at it.
/// </remarks>
public sealed class AbCipGatewayCompositionTests
{
private static AbCipTagCreateParams Params(string host, int port) =>
new(Gateway: host, Port: port, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
TagName: "TestDINT", Timeout: TimeSpan.FromSeconds(5));
[Fact]
public void The_authored_port_is_appended_to_the_gateway()
{
Params("10.100.0.35", 44900).LibplctagGateway.ShouldBe("10.100.0.35:44900");
}
[Fact]
public void The_default_port_is_still_written_explicitly()
{
Params("plc1.plant.local", 44818).LibplctagGateway.ShouldBe("plc1.plant.local:44818");
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void A_missing_port_falls_back_to_the_bare_host(int port)
{
Params("10.100.0.35", port).LibplctagGateway.ShouldBe("10.100.0.35");
}
}
@@ -41,13 +41,13 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim)
[MemberData(nameof(Profiles))]
public async Task Driver_reads_seeded_N_file_from_ab_server_PCCC(AbLegacyServerProfile profile)
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
// PCCC semantics allow an empty cip-path (real SLC/PLC-5 hardware takes nothing
// after the `/`), but libplctag's ab_server requires a non-empty path at the
// CIP unconnected-send layer before the PCCC dispatcher runs. Default `1,0`
// against the Docker fixture; set AB_LEGACY_CIP_PATH= (empty) against real HW.
var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}";
var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
@@ -75,23 +75,25 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim)
[AbLegacyFact]
public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
// Addresses the SLC500 container by its OWN port, so it no longer matters which other
// families happen to be running. The old AB_LEGACY_COMPOSE_PROFILE guard existed because every
// family shared :44818 and this test could otherwise write SLC500 semantics at whichever
// container held the port; it survives below only for real-hardware runs, where
// AB_LEGACY_ENDPOINT pins one device and the family IS a property of that device.
if (sim.SkipReasonFor(KnownProfiles.Slc500) is { } skip) Assert.Skip(skip);
// Skip when the running compose profile isn't SLC500 — ab_server's `--plc=`
// flag selects exactly one family per process, so a write against a plc5-mode
// container with SLC500 semantics always fails at the wire.
var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE");
if (!string.IsNullOrEmpty(only) &&
if (sim.EndpointPinned && !string.IsNullOrEmpty(only) &&
!string.Equals(only, "slc500", StringComparison.OrdinalIgnoreCase))
{
Assert.Skip($"Test targets the SLC500 compose profile; AB_LEGACY_COMPOSE_PROFILE='{only}'.");
Assert.Skip($"Pinned endpoint is not SLC500; AB_LEGACY_COMPOSE_PROFILE='{only}'.");
}
// PCCC semantics allow an empty cip-path (real SLC/PLC-5 hardware takes nothing
// after the `/`), but libplctag's ab_server requires a non-empty path at the
// CIP unconnected-send layer before the PCCC dispatcher runs. Default `1,0`
// against the Docker fixture; set AB_LEGACY_CIP_PATH= (empty) against real HW.
var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}";
var deviceUri = $"ab://{sim.Host}:{sim.PortFor(KnownProfiles.Slc500)}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)],
@@ -17,9 +17,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <remarks>
/// Env-var overrides:
/// <list type="bullet">
/// <item><c>AB_LEGACY_ENDPOINT</c> — <c>host:port</c> of the PCCC-mode simulator.
/// Defaults to <c>10.100.0.35:44818</c> — the shared Docker host (EtherNet/IP port; ab_server's PCCC
/// emulation exposes PCCC-over-CIP on the same port as CIP itself).</item>
/// <item><c>AB_LEGACY_ENDPOINT</c> — <c>host:port</c> of a single PCCC-mode device, pinning
/// <b>every</b> family to it (real-hardware mode, where the family is a property of the device
/// rather than of the port). Unset — the default — each family is reached on its own port on the
/// shared Docker host: <c>10.100.0.35:44819</c> slc500, <c>:44820</c> micrologix,
/// <c>:44821</c> plc5.</item>
/// <item><c>AB_LEGACY_CIP_PATH</c> — routing path appended to the <c>ab://host:port/</c>
/// URI. Defaults to <c>1,0</c> (port-1/slot-0 backplane), required by ab_server
/// which rejects unconnected Send_RR_Data with an empty path at the CIP layer
@@ -27,8 +29,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// use an empty path — set <c>AB_LEGACY_CIP_PATH=</c> (empty) when pointing at
/// real hardware.</item>
/// </list>
/// Distinct from <c>AB_SERVER_ENDPOINT</c> used by the AB CIP fixture so both
/// can point at different containers simultaneously during a combined test run.
/// Distinct from <c>AB_SERVER_ENDPOINT</c> used by the AB CIP fixture so both can point at different
/// containers simultaneously during a combined test run — which was the stated intent from the start
/// but was not true in practice until 2026-08-01, because every ab_server across both fixtures
/// defaulted to <c>:44818</c>.
/// </remarks>
public sealed class AbLegacyServerFixture : IAsyncLifetime
{
@@ -56,15 +60,58 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
/// <summary>Gets the server host address.</summary>
public string Host { get; } = "10.100.0.35";
/// <summary>Gets the server TCP port.</summary>
/// <summary>
/// The port every family shares when <c>AB_LEGACY_ENDPOINT</c> pins one, or
/// <see cref="DefaultPort"/>. Prefer <see cref="PortFor"/> — against the Docker fixture each
/// family listens on its own port and this value addresses none of them.
/// </summary>
public int Port { get; } = DefaultPort;
/// <summary>True when <c>AB_LEGACY_ENDPOINT</c> pinned a single endpoint (real-hardware mode).</summary>
public bool EndpointPinned { get; }
/// <summary>
/// The port to reach <paramref name="profile"/>'s simulator on: the family's own port against the
/// Docker fixture, or the pinned endpoint's port when <c>AB_LEGACY_ENDPOINT</c> is set.
/// </summary>
/// <param name="profile">The family profile under test.</param>
/// <returns>The TCP port.</returns>
public int PortFor(AbLegacyServerProfile profile)
{
ArgumentNullException.ThrowIfNull(profile);
return EndpointPinned ? Port : profile.Port;
}
/// <summary>
/// Why <paramref name="profile"/>'s simulator cannot be reached, or null when it can.
/// </summary>
/// <remarks>
/// ⚠️ <b>Per-family, not per-suite (2026-08-01).</b> Every family used to share <c>:44818</c>, so
/// this could only ever answer "is some ab_server up" — and the AB CIP fixture's ControlLogix
/// container answers there too. The probe therefore reported reachable, the suite declined to
/// skip, and SLC500-mode PCCC ran against a ControlLogix and failed at the wire. A reachability
/// check that cannot tell one simulator from another is not a skip gate; it is a coin flip that
/// reports its result as a test failure.
/// </remarks>
/// <param name="profile">The family profile under test.</param>
/// <returns>A skip reason, or <see langword="null"/>.</returns>
public string? SkipReasonFor(AbLegacyServerProfile profile)
{
ArgumentNullException.ThrowIfNull(profile);
var port = PortFor(profile);
if (TcpProbe(Host, port)) return null;
return EndpointPinned
? $"AB Legacy PCCC endpoint at {Host}:{port} ({EndpointEnvVar}) not reachable within 2 s."
: $"AB Legacy {profile.ComposeProfile} simulator at {Host}:{port} not reachable within 2 s. "
+ $"Start it (docker compose -f Docker/docker-compose.yml --profile {profile.ComposeProfile} "
+ $"up -d), attach real hardware, or override {EndpointEnvVar}.";
}
/// <summary>CIP routing path portion of the device URI (after the <c>/</c> separator).
/// May be empty when targeting real hardware; non-empty against ab_server.</summary>
public string CipPath { get; } = DefaultCipPath;
/// <summary>Gets the skip reason if the server is unavailable, or null if available.</summary>
public string? SkipReason { get; }
/// <summary>Initializes the AB Legacy server fixture by reading environment overrides.</summary>
public AbLegacyServerFixture()
@@ -74,6 +121,7 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
var parts = raw.Split(':', 2);
Host = parts[0];
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
EndpointPinned = true;
}
// Empty override is intentional (real hardware); treat `null` as "not set, use
@@ -81,7 +129,6 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
var cipOverride = Environment.GetEnvironmentVariable(CipPathEnvVar);
if (cipOverride is not null) CipPath = cipOverride;
SkipReason = ResolveSkipReason(Host, Port);
}
/// <summary>Initializes the fixture asynchronously.</summary>
@@ -97,29 +144,19 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
/// exists.
/// </summary>
public static bool IsServerAvailable()
{
var (host, port) = ResolveEndpoint();
return ResolveSkipReason(host, port) is null;
}
private static string? ResolveSkipReason(string host, int port)
{
if (!TcpProbe(host, port))
{
return $"AB Legacy PCCC endpoint at {host}:{port} not reachable within 2 s. " +
$"Start the Docker container (docker compose -f Docker/docker-compose.yml " +
$"--profile slc500 up -d), attach real hardware, or override {EndpointEnvVar}.";
}
return null;
}
private static (string Host, int Port) ResolveEndpoint()
{
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar);
if (raw is null) return ("10.100.0.35", DefaultPort);
var parts = raw.Split(':', 2);
var port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : DefaultPort;
return (parts[0], port);
if (raw is { Length: > 0 })
{
var parts = raw.Split(':', 2);
var pinned = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : DefaultPort;
return TcpProbe(parts[0], pinned);
}
// ANY family's simulator being up runs the class; each test then skips itself on its own port
// via SkipReasonFor. Gating the whole class on one family would skip the other two whenever it
// happened to be down, which reads as coverage that ran.
return KnownProfiles.All.Any(profile => TcpProbe("10.100.0.35", profile.Port));
}
private static bool TcpProbe(string host, int port)
@@ -140,27 +177,42 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
/// for which <c>--plc</c> mode + tags each family seeds; this record just ties a
/// family enum to its compose-profile name + operator-facing notes.
/// </summary>
/// <param name="Family">The PCCC family.</param>
/// <param name="ComposeProfile">The compose profile name that starts this family's container.</param>
/// <param name="Port">
/// The family's own host port. Each family gets one so all three run together, and alongside the
/// AB CIP fixture on <c>:44818</c>. Overridden wholesale by <c>AB_LEGACY_ENDPOINT</c> (real hardware,
/// where there is one device and the family is a property of it, not of the port).
/// </param>
/// <param name="Notes">Operator-facing description of what the family's container seeds.</param>
public sealed record AbLegacyServerProfile(
AbLegacyPlcFamily Family,
string ComposeProfile,
int Port,
string Notes);
/// <summary>Canonical profiles covering every PCCC family the driver supports.</summary>
/// <remarks>
/// The <c>Port</c> values must match <c>Docker/docker-compose.yml</c>'s host-side mappings.
/// </remarks>
public static class KnownProfiles
{
public static readonly AbLegacyServerProfile Slc500 = new(
Family: AbLegacyPlcFamily.Slc500,
ComposeProfile: "slc500",
Port: 44819,
Notes: "SLC 500 / 5/05 family. ab_server SLC500 mode covers N/F/B/L files.");
public static readonly AbLegacyServerProfile MicroLogix = new(
Family: AbLegacyPlcFamily.MicroLogix,
ComposeProfile: "micrologix",
Port: 44820,
Notes: "MicroLogix 1000 / 1100 / 1400. Shares N/F/B file-type coverage with SLC500; ST (ASCII strings) included.");
public static readonly AbLegacyServerProfile Plc5 = new(
Family: AbLegacyPlcFamily.Plc5,
ComposeProfile: "plc5",
Port: 44821,
Notes: "PLC-5 family. ab_server PLC/5 mode covers N/F/B; per-family quirks on ST / timer file layouts unit-tested only.");
/// <summary>Gets all known profiles.</summary>
@@ -13,7 +13,7 @@ Desktop and nothing else.
| File | Purpose |
|---|---|
| [`docker-compose.yml`](docker-compose.yml) | Three per-family services (`slc500` / `micrologix` / `plc5`); all bind `:44818` |
| [`docker-compose.yml`](docker-compose.yml) | Three per-family services — `slc500` on `:44819`, `micrologix` on `:44820`, `plc5` on `:44821`. Run them together; they no longer collide with each other or with the AB CIP fixture on `:44818`. |
## Run
@@ -38,12 +38,12 @@ docker compose -f tests\...\Docker\docker-compose.yml --profile slc500 down
First run builds the `otopcua-ab-server:libplctag-release` image (~3-5
min — clones libplctag + compiles `ab_server`). If the AB CIP fixture
already built the image locally, docker reuses the cached layers + this
runs in seconds. Only one family binds `:44818` at a time; to switch
runs in seconds. (Until 2026-08-01 every family bound `:44818`, so only one could run and the theory's other two cases always failed; each now has its own port.) To switch
families stop the current service + start another.
## Endpoint
- Default: `localhost:44818` (EtherNet/IP standard)
- Default: `10.100.0.35:44819` / `:44820` / `:44821` per family (the shared Docker host)
- Override with `AB_LEGACY_ENDPOINT=host:port` to point at a real SLC /
MicroLogix / PLC-5 PLC on its native port.
@@ -51,9 +51,9 @@ families stop the current service + start another.
| Var | Default | Purpose |
|---|---|---|
| `AB_LEGACY_ENDPOINT` | `localhost:44818` | `host:port` of the PCCC endpoint. |
| `AB_LEGACY_ENDPOINT` | *unset* — per-family ports on `10.100.0.35` | `host:port` of a single PCCC device, pinning **every** family to it (real-hardware mode). |
| `AB_LEGACY_CIP_PATH` | `1,0` | CIP routing path portion of the `ab://host:port/<path>` URI. ab_server rejects empty paths at the CIP unconnected-send layer; real SLC/MicroLogix/PLC-5 hardware accepts empty (no backplane). Set to empty (`AB_LEGACY_CIP_PATH=`) when pointing at real hardware. |
| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | When set (e.g. `slc500`), the parametric theory filters to that profile. Only one compose container binds `:44818` at a time; set this to the profile currently up so the suite doesn't try to hit e.g. the Slc500 family against the PLC-5 container. Leave unset for real-hardware runs (all 3 families parameterize). |
| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | Only consulted when `AB_LEGACY_ENDPOINT` pins an endpoint: names which family that one device is, so the SLC500-only write test skips against other hardware. Against the Docker fixture it is unnecessary — each family has its own port, and each test skips itself if its own port is down. |
## Run the integration tests
@@ -5,15 +5,20 @@
# so `docker compose build` from here produces the same image if it
# doesn't already exist; if it does, docker's cache reuses the layer.
#
# One service per PCCC family. All bind :44818 on the host; run one at a
# time. PCCC tag format differs from CIP: `<file>[<size>]` without a
# type suffix since the type is implicit in the file letter (N = INT,
# F = REAL, B = bit-packed, L = DINT).
# One service per PCCC family, each on its OWN host port — 44819 slc500,
# 44820 micrologix, 44821 plc5 — so all three run together, and alongside the
# AB CIP fixture, which owns :44818. They all bound :44818 until 2026-08-01,
# which made the suite structurally un-greenable: the theory parameterises over
# all three families, but only one container could ever exist, so at least two
# cases failed on every run. Worse, the AB CIP ControlLogix container answers
# on :44818 too, so the fixture's TCP reachability probe saw "up", declined to
# skip, and ran SLC500-mode PCCC against a ControlLogix.
#
# Usage:
# docker compose --profile slc500 up
# docker compose --profile micrologix up
# docker compose --profile plc5 up
# ab_server still listens on 44818 INSIDE the container (--port=44818); only the
# host mapping differs. Keep these in step with AbLegacyServerProfile.Port.
#
# Usage (all three at once — that is the point):
# docker compose --profile slc500 --profile micrologix --profile plc5 up -d
services:
slc500:
profiles: ["slc500"]
@@ -24,7 +29,7 @@ services:
container_name: otopcua-ab-server-slc500
restart: "no"
ports:
- "44818:44818"
- "44819:44818"
command: [
"ab_server",
"--plc=SLC500",
@@ -44,7 +49,7 @@ services:
container_name: otopcua-ab-server-micrologix
restart: "no"
ports:
- "44818:44818"
- "44820:44818"
command: [
"ab_server",
"--plc=Micrologix",
@@ -63,7 +68,7 @@ services:
container_name: otopcua-ab-server-plc5
restart: "no"
ports:
- "44818:44818"
- "44821:44818"
command: [
"ab_server",
"--plc=PLC/5",
@@ -0,0 +1,54 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Gitea #534 — the authored EtherNet/IP port must reach libplctag's <c>gateway</c> attribute.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="AbLegacyTagCreateParams.Port"/> was carried on the record and consumed by nobody:
/// the runtime set <c>Tag.Gateway = p.Gateway</c>, the bare host. libplctag does not fail on a
/// portless gateway — it substitutes 44818. So the authored port was not ignored in the harmless
/// sense; the connection was silently *redirected* to whatever answers on the default port. On the
/// test rig that was a different simulator running a different PLC family, which is why the
/// AbLegacy integration suite failed in a way that looked like a missing fixture. On a plant
/// network it is another PLC, and the driver would read someone else's tags and report them Good.
/// </para>
/// <para>
/// The real guard is the integration suite, which now addresses each PCCC family on its own
/// non-default port (44819/44820/44821) and therefore cannot pass unless the port is honoured.
/// These unit tests pin the composition itself so the rule survives on a machine with no fixtures.
/// </para>
/// </remarks>
public sealed class AbLegacyGatewayCompositionTests
{
private static AbLegacyTagCreateParams Params(string host, int port) =>
new(Gateway: host, Port: port, CipPath: "1,0", LibplctagPlcAttribute: "slc500",
TagName: "N7:0", Timeout: TimeSpan.FromSeconds(5));
[Fact]
public void The_authored_port_is_appended_to_the_gateway()
{
Params("10.100.0.35", 44819).LibplctagGateway.ShouldBe("10.100.0.35:44819");
}
[Fact]
public void The_default_port_is_still_written_explicitly()
{
// Not an optimisation opportunity: emitting the port unconditionally is what makes the value
// independent of libplctag's own default, so a change to that default cannot move our traffic.
Params("plc1.plant.local", 44818).LibplctagGateway.ShouldBe("plc1.plant.local:44818");
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void A_missing_port_falls_back_to_the_bare_host(int port)
{
// An unset port must not produce "host:0", which libplctag would take literally.
Params("10.100.0.35", port).LibplctagGateway.ShouldBe("10.100.0.35");
}
}
@@ -44,8 +44,11 @@ public class PageAuthorizationGuardTests
[typeof(Scripts)] = AdminUiPolicies.ConfigEditor,
[typeof(ScriptEdit)] = AdminUiPolicies.ConfigEditor,
// ── FleetAdmin (1) ──
// ── FleetAdmin (2) ──
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
// Minting a key hands out a data-plane credential, and the secret is shown once at creation —
// the same authority level as editing role grants.
[typeof(ApiKeys)] = AdminUiPolicies.FleetAdmin,
// ── Secrets (1): host wrapper for the ZB.MOM.WW.Secrets.Ui RCL page (lmxopcua#483) —
// carries the RCL page's own policy, registered additively by AddAdminUI via
@@ -178,11 +178,26 @@ public sealed class DriverProbeHandshakeE2eTests
// ControlLogix instead returns ErrorNotFound, which the probe classifies as
// reachable). Whether the `@raw_cpu_type` system-tag fallback is valid on a real
// ControlLogix is a hardware-gated follow-up (AbCipDriverOptions.cs flags it deferred).
// ⚠️ The config must use the v3 `RawTags` shape — `[{RawPath, TagConfig, WriteIdempotent,
// DeviceName}]`, where the Logix tag path lives inside the TagConfig blob as `tagPath`. This
// test passed the retired v2 `Tags: [{DeviceHostAddress, TagPath}]` shape until 2026-08-01, so
// AbCipDriverOptions.RawTags bound EMPTY, the probe fell through to its `@raw_cpu_type` system-tag
// fallback, and ab_server answered ErrorBadParam. Which is to say it spent that whole time
// exercising the one path the comment above says it does not exercise — and reporting it as a
// failure of the fixture rather than of the config shape.
var result = await new AbCipDriverProbe().ProbeAsync(
$"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]," +
$"\"Tags\":[{{\"DeviceHostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\",\"TagPath\":\"TestDINT\"}}]}}",
$"\"RawTags\":[{{\"RawPath\":\"Plant/AbCip/dev1/TestDINT\"," +
$"\"TagConfig\":\"{{\\\"tagPath\\\":\\\"TestDINT\\\",\\\"dataType\\\":\\\"DInt\\\"}}\"," +
$"\"WriteIdempotent\":false,\"DeviceName\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]}}",
Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
result.Message!.ShouldContain("CIP session OK");
// EXACT, not ShouldContain. The probe's two green messages are "CIP session OK" (the tag was
// read) and "CIP session OK (controller reachable; probe tag not found)" (the fallback), and
// `ShouldContain` matches both — so it would have gone green again the moment the config shape
// was merely well-formed enough to reach the controller, whether or not the authored tag was
// ever resolved. Reading the authored tag is the entire point of this test.
result.Message.ShouldBe("CIP session OK");
}
}
@@ -14,29 +14,20 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// admin path — not only the driver path — or every Test Connect button returns
/// "No probe registered for driver type X".
/// </summary>
/// <remarks>
/// ⚠️ <b>The expected set is derived from <see cref="DriverTypeNames.All"/>, not hand-written.</b>
/// It used to be a literal array kept "in sync" by comment, and it drifted the moment `Sql`
/// (<c>9b5a9687</c>) and `Mqtt` (<c>f79d13e2</c>) shipped: the count assertion below went red
/// reporting <i>12 where 10 expected</i> — a true statement that named neither driver and read like
/// the probes were wrong rather than the list. Worse is the direction it could NOT catch: a new
/// driver type registered with <b>no</b> probe passed silently, because a hand-written list grows
/// only when someone remembers to grow it, and this test exists precisely for the case where nobody
/// did. Deriving from the constants makes the guard bidirectional and self-maintaining.
/// </remarks>
public sealed class DriverProbeRegistrationTests
{
// The canonical "all drivers" set — one entry per AdminUI typed driver page's DriverTypeKey.
// Keep in sync with the IDriverProbe registrations in
// src/Server/.../Host/Drivers/DriverFactoryBootstrap.AddOtOpcUaDriverProbes (the routed
// *DriverPage.razor flow was retired in v3 Batch 2 — the driver-type keys now live in the /raw
// modals, but the probe set is the authority for what the AdminUI can Test-connect).
private static readonly string[] AdminUiDriverTypeKeys =
[
"Modbus",
"AbCip",
"AbLegacy",
"S7",
"TwinCat", // page key; probe reports "TwinCAT" — must resolve case-insensitively
"Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively
"OpcUaClient",
"GalaxyMxGateway",
"Calculation", // v3 Batch 2 pseudo-driver; CalculationProbe registered in DriverFactoryBootstrap
"MTConnect", // MTConnectProbe — read-only Agent driver; probe issues a bare GET /probe
];
[Fact]
public void AddOtOpcUaDriverProbes_registers_a_probe_for_every_AdminUI_driver_type()
public void AddOtOpcUaDriverProbes_registers_a_probe_for_every_driver_type()
{
var services = new ServiceCollection();
services.AddOtOpcUaDriverProbes();
@@ -48,8 +39,50 @@ public sealed class DriverProbeRegistrationTests
// (case-insensitive) and would throw on a duplicate key, crashing the singleton.
var byType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
foreach (var key in AdminUiDriverTypeKeys)
byType.ContainsKey(key).ShouldBeTrue($"No IDriverProbe registered for AdminUI driver type '{key}'.");
var missing = DriverTypeNames.All.Where(t => !byType.ContainsKey(t)).ToList();
missing.ShouldBeEmpty(
"Driver types with no IDriverProbe — Test Connect returns 'No probe registered for driver "
+ $"type X' for each: {string.Join(", ", missing)}");
}
[Fact]
public void No_probe_is_registered_for_a_driver_type_that_does_not_exist()
{
// The other direction. A probe whose DriverType matches no DriverTypeNames constant can never
// be reached — AdminOperationsActor looks probes up by the authored DriverType — so it is a
// typo or a leftover from a renamed driver, and it reads as coverage while providing none.
var services = new ServiceCollection();
services.AddOtOpcUaDriverProbes();
using var sp = services.BuildServiceProvider();
var orphans = sp.GetServices<IDriverProbe>()
.Select(p => p.DriverType)
.Where(t => !DriverTypeNames.All.Contains(t, StringComparer.OrdinalIgnoreCase))
.ToList();
orphans.ShouldBeEmpty($"Probes for unknown driver types: {string.Join(", ", orphans)}");
}
/// <summary>
/// Two driver types are spelled differently by the constant and by the AdminUI's own key
/// (<c>TwinCAT</c>/<c>TwinCat</c>, <c>FOCAS</c>/<c>Focas</c>), so the lookup must fold case. The
/// derived tests above cannot cover this — they compare the constants against themselves.
/// </summary>
[Theory]
[InlineData("TwinCat", DriverTypeNames.TwinCAT)]
[InlineData("Focas", DriverTypeNames.FOCAS)]
[InlineData("modbus", DriverTypeNames.Modbus)]
public void A_probe_resolves_by_a_case_drifted_driver_type_key(string authoredKey, string canonical)
{
var services = new ServiceCollection();
services.AddOtOpcUaDriverProbes();
using var sp = services.BuildServiceProvider();
var byType = sp.GetServices<IDriverProbe>().ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
byType.ShouldContainKey(authoredKey);
byType[authoredKey].DriverType.ShouldBe(canonical);
}
[Fact]
@@ -67,6 +100,6 @@ public sealed class DriverProbeRegistrationTests
var distinctTypes = probes.Select(p => p.DriverType).Distinct(StringComparer.OrdinalIgnoreCase).Count();
probes.Count.ShouldBe(distinctTypes, "Duplicate IDriverProbe registrations — TryAddEnumerable should de-dup.");
distinctTypes.ShouldBe(AdminUiDriverTypeKeys.Length);
distinctTypes.ShouldBe(DriverTypeNames.All.Count);
}
}
@@ -423,6 +423,21 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
ab.WithOtOpcUaClusterBootstrap(sp);
ab.WithOtOpcUaControlPlaneSingletons();
ab.WithOtOpcUaRuntimeActors();
// Production fidelity, per-cluster mesh Phase 6 (`a2c5d57f`). RedundancyStateActor moved OUT
// of WithOtOpcUaControlPlaneSingletons and into its own cluster-role-scoped registration,
// which Program.cs makes from its DRIVER branch — a driver-only site pair has no admin node
// to elect its Primary. The harness models a fused admin,driver node but was never updated,
// so from that commit until now NO node here spawned the singleton: nothing published
// `redundancy-state`, every node sat at "role unknown" forever, and PrimaryGateFailoverTests
// polled for 45s and timed out.
//
// This is the second time this exact shape has bitten the harness — see the AddOtOpcUaRuntime
// note above, where calling only the Akka-spawn half left IDriverHealthPublisher on its Null
// default. When Program.cs grows an actor registration, this list has to grow with it; the
// harness's whole value is that it is wired the way a real node is.
ab.WithOtOpcUaClusterRedundancySingleton(
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<AkkaClusterOptions>>().Value);
});
builder.Services.AddOtOpcUaAuth(builder.Configuration);
@@ -15,6 +15,12 @@ public sealed class ArtifactApiKeyStoreTests
{
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
/// <summary>
/// No test here exercises a narrower-than-Cluster scope, so the resolved scope paths are empty.
/// </summary>
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
[Fact]
public async Task A_node_that_has_not_been_deployed_to_authenticates_nothing()
{
@@ -29,7 +35,7 @@ public sealed class ArtifactApiKeyStoreTests
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "scadabridge-dcl")],
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], NoScopePaths);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
@@ -41,16 +47,18 @@ public sealed class ArtifactApiKeyStoreTests
[Fact]
public async Task Only_cluster_scoped_grants_are_returned()
{
// Matches ConfigDbApiKeyStore, which filters the same way in SQL. Narrower scopes are not yet
// enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately under-grants rather than
// promoting a scoped grant's flags to server-wide authority.
// Narrower scopes are not yet enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately
// under-grants rather than promoting a scoped grant's flags to server-wide authority. Scope kind
// is the only narrowing this store performs — cluster confinement already happened in
// ParseDataPlaneAuthority, which is where it differs from ConfigDbApiKeyStore (that one filters
// on cluster itself; see ConfigDbApiKeyStoreTests).
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "p")],
[
Acl("acl-cluster", "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
]);
], NoScopePaths);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
@@ -65,12 +73,12 @@ public sealed class ArtifactApiKeyStoreTests
var state = new DataPlaneAuthorityState();
var sut = new ArtifactApiKeyStore(state);
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], [], NoScopePaths);
(await sut.FindAsync("abc", Now, default)).ShouldNotBeNull();
var revoked = Key("abc", "p");
revoked.Enabled = false;
state.Apply("cluster-a", "rev-2", [revoked], []);
state.Apply("cluster-a", "rev-2", [revoked], [], NoScopePaths);
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
}
@@ -81,7 +89,8 @@ public sealed class ArtifactApiKeyStoreTests
// The anonymous principal has grants but obviously no credential, so FindGrantsAsync is its path.
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1", [],
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)]);
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)],
NoScopePaths);
var grants = await new ArtifactApiKeyStore(state).FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
@@ -0,0 +1,227 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
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;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// The key source for a node WITH a ConfigDb (#520 stage 2) — an admin-role node, including a fused
/// admin+driver one.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why cluster confinement is tested here and not on <c>ArtifactApiKeyStore</c>.</b> The two
/// stores read different things. The artifact store's snapshot was already narrowed to the node's
/// own cluster by <c>DeploymentArtifact.ParseDataPlaneAuthority</c>
/// (<c>ClusterFilterMode.ScopeTo</c>), so it cannot see another cluster's rows at all. This store
/// queries the fleet-wide ConfigDb, so the confinement is its own job — and until it was added, a
/// Cluster-scoped grant authored in ANY cluster conferred an interim server-wide role on a fused
/// central node.
/// </para>
/// <para>
/// The in-memory EF provider is the convention in the ControlPlane/Runtime test projects; nothing
/// here touches the central SQL Server. Note it compares strings with LINQ-to-Objects ordinal
/// semantics, NOT SQL Server's case-insensitive collation — which is exactly why the ClusterId
/// match is done explicitly with <see cref="StringComparison.OrdinalIgnoreCase"/> in the store
/// rather than left to the provider. <see cref="A_ClusterId_matches_case_insensitively"/> pins it.
/// </para>
/// </remarks>
public sealed class ConfigDbApiKeyStoreTests
{
private const string ThisCluster = "SITE-A";
private const string OtherCluster = "SITE-B";
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
/// <summary>
/// THE REGRESSION TEST. A Cluster-scoped grant authored in another cluster must not reach this
/// node — it would collapse into a server-wide <c>WriteOperate</c> role via
/// <c>ApiKeyInterimRoleMapper</c>, i.e. write authority granted in SITE-B taking effect in SITE-A.
/// </summary>
[Fact]
public async Task A_cluster_scoped_grant_from_another_cluster_confers_nothing()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "scadabridge-dcl")],
acls: [Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
// The KEY still resolves — keys are fleet-wide credentials (ApiKey carries no ClusterId); it is
// the grants that are cluster-bound. Asserting a null lookup here would pass for the wrong reason.
found.ShouldNotBeNull();
found!.Grants.ShouldBeEmpty();
}
/// <summary>
/// The positive control for the test above: without it, a "fix" that returned no grants at all
/// would pass.
/// </summary>
[Fact]
public async Task A_cluster_scoped_grant_in_this_cluster_still_confers_its_permissions()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "scadabridge-dcl")],
acls:
[
Acl("acl-mine", ThisCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
]);
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine");
}
/// <summary>
/// A legacy / single-cluster node carries no <c>cluster-{ClusterId}</c> role, so its ClusterId is
/// null and NO filter applies — mirroring the artifact path's <c>ClusterFilterMode.None</c>.
/// Failing closed here would silently strip every grant on an existing deployment.
/// </summary>
[Fact]
public async Task A_node_with_no_cluster_scoped_role_sees_every_cluster_scoped_grant()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "p")],
acls:
[
Acl("acl-a", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-b", OtherCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
]);
var found = await new ConfigDbApiKeyStore(factory, clusterId: null).FindAsync("abc", Now, default);
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-a", "acl-b"], ignoreOrder: true);
}
/// <summary>
/// The node's ClusterId comes from its role suffix, and that arrives in either the operator's
/// spelling (<c>Cluster:Roles</c> binds verbatim) or lower-cased (<c>OTOPCUA_ROLES</c> goes through
/// <c>RoleParser.Parse</c>, which calls <c>ToLowerInvariant</c>). The ACL row's ClusterId is
/// authored independently. A case-sensitive match would therefore strip every grant on a node
/// configured the second way — so the match is OrdinalIgnoreCase, the same comparison
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> uses.
/// </summary>
[Fact]
public async Task A_ClusterId_matches_case_insensitively()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "p")],
acls: [Acl("acl-mine", "SITE-A", "p", NodeAclScopeKind.Cluster, NodePermissions.Read)]);
var found = await new ConfigDbApiKeyStore(factory, "site-a").FindAsync("abc", Now, default);
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine");
}
/// <summary>
/// <c>FindGrantsAsync</c> is the anonymous principal's path — no credential, so it never goes
/// through <c>FindAsync</c>. It must be confined to the node's cluster too.
/// </summary>
[Fact]
public async Task Anonymous_grants_are_cluster_confined_on_their_own_path()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [],
acls:
[
Acl("acl-anon-mine", ThisCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-anon-other", OtherCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
]);
var grants = await new ConfigDbApiKeyStore(factory, ThisCluster)
.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-anon-mine");
}
/// <summary>
/// Narrower-than-Cluster scopes are still excluded (stage 5 widens this) — the cluster filter is
/// an ADDITIONAL narrowing, not a replacement for the scope-kind one.
/// </summary>
[Fact]
public async Task A_narrower_scope_in_this_cluster_is_still_excluded()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "p")],
acls:
[
Acl("acl-cluster", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-equipment", ThisCluster, "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
]);
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-cluster");
}
/// <summary>
/// The ClusterId derivation itself: the same one <c>ClusterRoleInfo</c> performs for
/// <c>IClusterRoleInfo.ClusterId</c>, off the node's OWN configured roles.
/// </summary>
[Fact]
public void ResolveClusterId_reads_the_nodes_own_cluster_scoped_role()
{
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["driver", "cluster-SITE-A"] })
.ShouldBe("SITE-A");
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["admin", "driver"] })
.ShouldBeNull();
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions())
.ShouldBeNull();
}
private static void Seed(
IDbContextFactory<OtOpcUaConfigDbContext> factory,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> acls)
{
using var db = factory.CreateDbContext();
db.ApiKeys.AddRange(keys);
db.NodeAcls.AddRange(acls);
db.SaveChanges();
}
private static ApiKey Key(string id, string principal) => new()
{
ApiKeyRowId = Guid.NewGuid(),
ApiKeyId = id,
Name = id,
SecretHash = "hash",
Principal = principal,
Enabled = true,
CreatedUtc = Now.AddDays(-1),
};
private static NodeAcl Acl(
string id, string clusterId, string principal, NodeAclScopeKind scope, NodePermissions flags) => new()
{
NodeAclRowId = Guid.NewGuid(),
NodeAclId = id,
ClusterId = clusterId,
Principal = principal,
ScopeKind = scope,
PermissionFlags = flags,
};
private static IDbContextFactory<OtOpcUaConfigDbContext> NewInMemoryDbFactory() =>
new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"));
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <summary>Creates a context over this test's own in-memory database.</summary>
/// <returns>A new <see cref="OtOpcUaConfigDbContext"/>.</returns>
public OtOpcUaConfigDbContext CreateDbContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(dbName).Options);
}
}
@@ -10,6 +10,9 @@
<ItemGroup>
<PackageReference Include="xunit"/>
<PackageReference Include="Shouldly"/>
<!-- ConfigDbApiKeyStoreTests drives the store against a real EF query, the same in-memory
provider convention the ControlPlane/Runtime test projects use. No central SQL Server. -->
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
@@ -0,0 +1,183 @@
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// End-to-end coverage of the ACL scope path as it actually runs on a node (#520 stage 3):
/// artifact bytes → <see cref="DeploymentArtifact.ParseDataPlaneAuthority"/> → scope paths →
/// <see cref="PermissionTrieBuilder.Build"/> → <see cref="PermissionTrie.CollectMatches"/>.
/// </summary>
/// <remarks>
/// <para>
/// This is the coverage the design doc called out as missing, and it was missing in a specific
/// way worth remembering: the pre-stage-3 tests all hand-built the <c>scopePaths</c> dictionary in
/// the test body with exactly the segments the walker expected, so the walker was pinned against
/// the test's idea of the format rather than against any producer. Nothing proved a real artifact
/// could yield a matching grant, and in fact nothing could have — there was no producer at all.
/// </para>
/// <para>
/// So the artifact JSON here is deliberately hand-written rather than composed: it is standing in
/// for the wire format, and the parser must agree with <em>it</em>. The composer→parser agreement
/// is pinned separately, in <c>ControlPlane.Tests/DataPlaneAuthorityArtifactTests</c>.
/// </para>
/// </remarks>
public sealed class DeploymentArtifactScopePathTests
{
private const string NodeId = "node-a:4840";
// Plant/Line1 → ModbusDrv → dev1 → Fast → Speed, projected into equipment West/L1/Mixer as "MixerSpeed".
private const string Artifact = """
{
"RawFolders": [
{"RawFolderId":"RF-plant","ClusterId":"c1","ParentRawFolderId":null,"Name":"Plant"},
{"RawFolderId":"RF-line1","ClusterId":"c1","ParentRawFolderId":"RF-plant","Name":"Line1"}
],
"DriverInstances": [
{"DriverInstanceId":"DI-modbus","ClusterId":"c1","RawFolderId":"RF-line1","Name":"ModbusDrv"}
],
"Devices": [{"DeviceId":"DV-1","DriverInstanceId":"DI-modbus","Name":"dev1"}],
"TagGroups": [{"TagGroupId":"TG-fast","DeviceId":"DV-1","ParentTagGroupId":null,"Name":"Fast"}],
"Tags": [{"TagId":"TAG-speed","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Speed"}],
"UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}],
"UnsLines": [{"UnsLineId":"UL-1","UnsAreaId":"UA-w","Name":"L1"}],
"Equipment": [{"EquipmentId":"EQ-mixer","UnsLineId":"UL-1","Name":"Mixer"}],
"UnsTagReferences": [
{"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed","DisplayNameOverride":"MixerSpeed"}
],
"NodeAcls": [
{"NodeAclId":"acl-dev","ClusterId":"c1","Principal":"p","ScopeKind":9,"ScopeId":"DV-1","PermissionFlags":2},
{"NodeAclId":"acl-eq","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16},
{"NodeAclId":"acl-dangling","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-gone","PermissionFlags":16}
]
}
""";
private static (PermissionTrie Trie, List<PermissionTrieBuildDiagnostic> Diagnostics) BuildFromArtifact()
{
var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId);
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var trie = PermissionTrieBuilder.Build("c1", 1, projection.Grants, projection.ScopePaths, diagnostics.Add);
return (trie, diagnostics);
}
[Fact]
public void A_raw_device_grant_from_the_artifact_matches_a_tag_beneath_that_device()
{
var (trie, _) = BuildFromArtifact();
var matches = trie.CollectMatches(
new NodeScope
{
ClusterId = "c1",
Kind = NodeHierarchyKind.Raw,
Segments = ["Plant", "Line1", "ModbusDrv", "dev1", "Fast", "Speed"],
},
["p"]);
var match = matches.ShouldHaveSingleItem();
match.PermissionFlags.ShouldBe(NodePermissions.Read);
// Provenance is the authored kind, not the depth the grant happens to sit at.
match.Scope.ShouldBe(NodeAclScopeKind.RawDevice);
}
[Fact]
public void A_tag_under_a_DIFFERENT_device_does_not_match_that_grant()
{
// The negative control. Without it the test above passes for any trie that returns every grant it
// holds regardless of path — which is exactly what a broken walk would do.
var (trie, _) = BuildFromArtifact();
trie.CollectMatches(
new NodeScope
{
ClusterId = "c1",
Kind = NodeHierarchyKind.Raw,
Segments = ["Plant", "Line1", "ModbusDrv", "otherdev", "Fast", "Speed"],
},
["p"]).ShouldBeEmpty();
}
[Fact]
public void The_same_grant_set_matches_through_the_UNS_realm_too()
{
// The equipment grant is authored once and must be reachable at the UNS NodeId, which is a
// completely different path from the raw one for the same underlying value.
var (trie, _) = BuildFromArtifact();
var matches = trie.CollectMatches(
new NodeScope
{
ClusterId = "c1",
Kind = NodeHierarchyKind.Uns,
Segments = ["West", "L1", "Mixer", "MixerSpeed"],
},
["p"]);
matches.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
}
[Fact]
public void A_raw_scope_does_not_pick_up_a_UNS_grant_at_the_same_depth()
{
// Realm isolation. "West/L1/Mixer" and a three-segment raw path are the same depth, so without the
// realm gate at level 1 a UNS grant would match a raw node whose folder/driver/device happened to
// be named the same. The two trees are authored independently, so that collision is not exotic.
var (trie, _) = BuildFromArtifact();
trie.CollectMatches(
new NodeScope
{
ClusterId = "c1",
Kind = NodeHierarchyKind.Raw,
Segments = ["West", "L1", "Mixer", "MixerSpeed"],
},
["p"]).ShouldBeEmpty();
}
[Fact]
public void A_dangling_scope_is_dropped_with_a_diagnostic_rather_than_planted()
{
var (_, diagnostics) = BuildFromArtifact();
var diagnostic = diagnostics.ShouldHaveSingleItem();
diagnostic.NodeAclId.ShouldBe("acl-dangling");
diagnostic.Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
}
[Fact]
public void Case_drift_in_a_path_segment_grants_nothing()
{
// Raw sibling names are unique under an ordinal comparer, so "Speed" and "speed" can both exist
// under one device. The trie compared OrdinalIgnoreCase before stage 3, which would have let a
// grant on either match both. This goes red if the comparer is reverted.
var (trie, _) = BuildFromArtifact();
trie.CollectMatches(
new NodeScope
{
ClusterId = "c1",
Kind = NodeHierarchyKind.Raw,
Segments = ["plant", "Line1", "ModbusDrv", "dev1", "Fast", "Speed"],
},
["p"]).ShouldBeEmpty();
}
[Fact]
public void An_artifact_with_no_topology_resolves_no_scopes()
{
var projection = DeploymentArtifact.ParseDataPlaneAuthority(
Encoding.UTF8.GetBytes("""
{"NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-x","PermissionFlags":1}]}
"""),
NodeId);
projection.Grants.ShouldHaveSingleItem();
projection.ScopePaths.ShouldBeEmpty();
}
}