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:
@@ -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_<keyId>_<secret></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_<keyId>_<secret></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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user