feat(acl): variable-depth scope model + the scopePaths producer (#520 stage 3)

Makes the /raw device tree addressable by an ACL rule, and builds the
scope-path producer PermissionTrieBuilder has accepted since it was written
but which had no caller anywhere.

The plan for this stage said "add NodeHierarchyKind.Raw, whose path is the
RawPath segments Folder/Driver/Device/TagGroup/Tag". That shape is wrong.
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 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,
Segments} and the walk is a segment loop. The trie STRUCTURE was already
variable-depth (NodeAclPath.Segments is a list, 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 gated on NodeScope.NamespaceId,
  but v3 has no Namespace entity — the snapshot was retired when the address
  space went dual-namespace, and the identifier appears nowhere in production
  outside NodeScope. So the level could only ever fail to match, which silently
  made every sub-cluster grant unreachable while cluster grants kept working.
  It is now a realm token derived from the scope's Kind, not carried on it, so
  the walk and the producer cannot disagree about its spelling.
- Provenance comes from the granting row's own ScopeKind, recorded on TrieGrant
  at build time. Depth-derived provenance could not name a raw level at all,
  since the depth of "Device" varies with folder nesting.
- 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.
- 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. scopePaths is
  now required, and a row that does not resolve is dropped with a diagnostic.

A scope resolves to a LIST of paths: 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 covering only the raw node would be
bypassable through a UNS one. The lookup is keyed on (ScopeKind, ScopeId), not
ScopeId alone — ScopeId has no FK and no type discriminator, so an id collision
across entity types would hand a grant the wrong node's paths.

Also closed here, all found by review rather than asked for:

- NodeAcl rows had no deploy-time validation of any kind and were not reachable
  from DraftValidator at all (DraftSnapshot carried no NodeAcls). They are now
  gated for dangling ids, cross-cluster scopes, blank/contradictory ScopeId, the
  retired FolderSegment kind, and undefined ordinals.
- ConfigDbApiKeyStore conferred roles from Cluster-scoped grants authored in ANY
  cluster, because it filtered on scope kind with no ClusterId predicate while
  ArtifactApiKeyStore read an already-cluster-filtered snapshot. On a fused
  admin+driver node that was a cross-cluster over-grant. Its doc comment claimed
  the two stores matched.
- The two AdminUI ACL banners still said enforcement was LDAP-group -> role
  mapping, which stages 1/2 made false in both directions.
- The /apikeys page was never classified in PageAuthorizationGuardTests, so that
  test has been red since the key-minting commit merged — I did not run
  AdminUI.Tests before merging stage 4.

Falsified rather than assumed: 8 mutations against the trie tests, 5 against the
deploy gate (including one proving a cross-cluster row is not silently
mis-diagnosed as unresolved), a staged red->green for the over-grant regression,
and the realm gate, the comparer and the malformed-path guard each broken
individually to confirm only their own tests go red.

Node ACLs are still not ENFORCED — nothing constructs a NodeScope or calls the
evaluator yet. That is stages 5 and 6; this stage makes the model able to
express what those will enforce.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 01:07:59 -04:00
parent fbe7403ca5
commit 3a6b2a1daf
28 changed files with 3063 additions and 363 deletions
@@ -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
{
@@ -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,57 @@ 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>, reusing the same <see cref="RoleParser"/> primitives —
/// first cluster-scoped role wins, as it does there.
/// </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);
var clusterRole = options.Roles.FirstOrDefault(RoleParser.IsClusterRole);
return clusterRole is null ? null : RoleParser.ClusterIdFromRole(clusterRole);
}
/// <inheritdoc />
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
@@ -53,15 +111,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)
{