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:
@@ -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,235 @@ 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 ordinal, untrimmed 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.
|
||||
/// </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.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => value(g.First()), StringComparer.Ordinal);
|
||||
|
||||
// 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.Ordinal))
|
||||
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,288 @@
|
||||
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>
|
||||
/// 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.
|
||||
/// </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>
|
||||
/// 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);
|
||||
|
||||
_folders = folders;
|
||||
_drivers = drivers;
|
||||
_devices = devices;
|
||||
_groups = groups;
|
||||
_tags = tags;
|
||||
_areas = areas;
|
||||
_lines = lines;
|
||||
_equipment = equipment;
|
||||
_references = references;
|
||||
|
||||
_rawPaths = new RawPathResolver(
|
||||
folders,
|
||||
drivers,
|
||||
devices,
|
||||
groups.ToDictionary(kv => kv.Key, kv => (kv.Value.ParentId, kv.Value.Name), StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <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, so an operator who still
|
||||
// has such a row gets a diagnostic naming it rather than a grant that quietly does nothing.
|
||||
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.Ordinal)) 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];
|
||||
}
|
||||
|
||||
// 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,116 @@ 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)
|
||||
// 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 +126,41 @@ 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, a deleted entity, or a scope kind with no v3 meaning (<see cref="NodeAclScopeKind.FolderSegment"/>).
|
||||
/// </summary>
|
||||
MissingScopePath,
|
||||
UnresolvedScope,
|
||||
|
||||
/// <summary>The row is narrower than <see cref="NodeAclScopeKind.Cluster"/> but carries no <c>ScopeId</c>.</summary>
|
||||
MissingScopeId,
|
||||
|
||||
/// <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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user