docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
This commit is contained in:
Joseph Doherty
2026-07-07 12:38:39 -04:00
parent 384dbd7d36
commit 9cad9ed0fc
375 changed files with 1899 additions and 2493 deletions
@@ -4,8 +4,8 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// Tri-state result of an <see cref="IPermissionEvaluator.Authorize"/> call, per decision
/// #149. Phase 6.2 only produces <see cref="AuthorizationVerdict.Allow"/> and
/// Tri-state result of an <see cref="IPermissionEvaluator.Authorize"/> call.
/// Phase 6.2 only produces <see cref="AuthorizationVerdict.Allow"/> and
/// <see cref="AuthorizationVerdict.NotGranted"/>; the <see cref="AuthorizationVerdict.Denied"/>
/// variant exists in the model so v2.1 Explicit Deny lands without an API break. Provenance
/// carries the matched grants (or empty when not granted) for audit + the Admin UI "Probe
@@ -19,6 +19,7 @@ public sealed record AuthorizationDecision(
public bool IsAllowed => Verdict == AuthorizationVerdict.Allow;
/// <summary>Convenience constructor for the common "no grants matched" outcome.</summary>
/// <returns>An authorization decision with the <see cref="AuthorizationVerdict.NotGranted"/> verdict.</returns>
public static AuthorizationDecision NotGranted() => new(AuthorizationVerdict.NotGranted, []);
/// <summary>Allow with the list of grants that matched.</summary>
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <remarks>
/// Data-plane only. Reads <c>NodeAcl</c> rows joined against the session's resolved LDAP
/// groups (via <see cref="UserAuthorizationState"/>). Must not depend on the control-plane
/// admin-role mapping table per decision #150 — the two concerns share zero runtime code.
/// admin-role mapping table — the two concerns share zero runtime code.
/// </remarks>
public interface IPermissionEvaluator
{
@@ -22,5 +22,6 @@ public interface IPermissionEvaluator
/// <param name="session">The user session containing resolved LDAP groups and roles.</param>
/// <param name="operation">The OPC UA operation being requested.</param>
/// <param name="scope">The node address scope being accessed.</param>
/// <returns>An authorization decision indicating whether the operation is allowed.</returns>
AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope);
}
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// to <see cref="IPermissionEvaluator"/> which walks the matching trie path.
/// </summary>
/// <remarks>
/// <para>Per decision #129 and the Phase 6.2 Stream B plan the hierarchy is
/// <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>
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// requested <see cref="Abstractions.OpcUaOperation"/>.
/// </summary>
/// <remarks>
/// Per decision #129 (additive grants, no explicit Deny in v2.0) the walk is pure union:
/// 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.
@@ -33,6 +33,7 @@ public sealed class PermissionTrie
/// </summary>
/// <param name="scope">The node scope to match permissions for.</param>
/// <param name="ldapGroups">The user's LDAP group memberships.</param>
/// <returns>The matched grants accumulated across every trie level visited for the scope.</returns>
public IReadOnlyList<MatchedGrant> CollectMatches(NodeScope scope, IEnumerable<string> ldapGroups)
{
ArgumentNullException.ThrowIfNull(scope);
@@ -37,10 +37,11 @@ public static class PermissionTrieBuilder
/// <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 the
/// Core-011 production hazard. The callback fires only when <paramref name="scopePaths"/>
/// 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).
/// </param>
/// <returns>The built <see cref="PermissionTrie"/> for the cluster + generation.</returns>
public static PermissionTrie Build(
string clusterId,
long generationId,
@@ -128,8 +129,7 @@ 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
/// (Core-011).
/// orphaned grants surface instead of being silently dropped under an unreachable node.
/// </summary>
/// <param name="NodeAclId">The offending row's logical id.</param>
/// <param name="ScopeKind">The row's <see cref="NodeAclScopeKind"/>.</param>
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <see cref="Install(PermissionTrie)"/>.
/// </summary>
/// <remarks>
/// Per decision #148 and Phase 6.2 Stream B.4 the cache is generation-sealed: once a
/// Per Phase 6.2 Stream B.4 the cache is generation-sealed: once a
/// trie is installed for <c>(ClusterId, GenerationId)</c> the entry is immutable. When a
/// new generation publishes, the caller calls <see cref="Install"/> with the new trie
/// + the cache atomically updates its "current generation" pointer for that cluster.
@@ -34,6 +34,7 @@ public sealed class PermissionTrieCache
/// <summary>Get the current-generation trie for a cluster; null when nothing installed.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <returns>The current trie for the cluster, or null when nothing is installed.</returns>
public PermissionTrie? GetTrie(string clusterId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
@@ -43,6 +44,7 @@ public sealed class PermissionTrieCache
/// <summary>Get a specific (cluster, generation) trie; null if that pair isn't cached.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <param name="generationId">The generation identifier.</param>
/// <returns>The trie for that cluster and generation, or null if that pair isn't cached.</returns>
public PermissionTrie? GetTrie(string clusterId, long generationId)
{
if (!_byCluster.TryGetValue(clusterId, out var entry)) return null;
@@ -51,6 +53,7 @@ public sealed class PermissionTrieCache
/// <summary>The generation id the <see cref="GetTrie(string)"/> shortcut currently serves for a cluster.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <returns>The current generation id for the cluster, or null if nothing is cached.</returns>
public long? CurrentGenerationId(string clusterId)
=> _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current.GenerationId : null;
@@ -111,11 +114,13 @@ public sealed class PermissionTrieCache
/// <summary>Creates a cluster entry from a single trie.</summary>
/// <param name="trie">The permission trie to create the entry from.</param>
/// <returns>A new cluster entry whose current trie and generation map both hold only <paramref name="trie"/>.</returns>
public static ClusterEntry FromSingle(PermissionTrie trie) =>
new(trie, new Dictionary<long, PermissionTrie> { [trie.GenerationId] = trie });
/// <summary>Creates a new entry with an additional trie, updating current if it's newer.</summary>
/// <param name="trie">The new permission trie to add.</param>
/// <returns>A new cluster entry with the trie merged in and the current pointer updated if needed.</returns>
public ClusterEntry WithAdditional(PermissionTrie trie)
{
var next = new Dictionary<long, PermissionTrie>(Tries) { [trie.GenerationId] = trie };
@@ -24,18 +24,12 @@ public sealed class TriePermissionEvaluator : IPermissionEvaluator
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>Authorizes an operation against the user's session and node scope.</summary>
/// <param name="session">The user's authorization session.</param>
/// <param name="operation">The OPC UA operation to authorize.</param>
/// <param name="scope">The target node scope.</param>
/// <returns>An authorization decision indicating whether the operation is allowed.</returns>
/// <inheritdoc />
public AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(scope);
// Decision #152 — beyond the staleness ceiling every call fails closed regardless of
// cache warmth elsewhere in the process.
if (session.IsStale(_timeProvider.GetUtcNow().UtcDateTime))
return AuthorizationDecision.NotGranted();
@@ -45,12 +39,6 @@ public sealed class TriePermissionEvaluator : IPermissionEvaluator
var trie = _cache.GetTrie(scope.ClusterId);
if (trie is null) return AuthorizationDecision.NotGranted();
// Decision #153 / Phase 6.2 adversarial-review item #3 (redundancy-safe invalidation):
// the GetTrie shortcut returns whatever generation the cache currently holds, which may
// have advanced past the generation this session was bound to (another node published).
// Evaluate against the session's *bound* generation so a grant added or removed in a
// newer generation cannot silently take effect mid-session, and so the provenance in the
// AuthorizationDecision reports the generation that actually produced the verdict.
if (trie.GenerationId != session.AuthGenerationId)
{
trie = _cache.GetTrie(scope.ClusterId, session.AuthGenerationId);
@@ -6,12 +6,12 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// the membership was resolved against, and the bounded freshness window.
/// </summary>
/// <remarks>
/// Per decision #151 the membership is bounded by <see cref="MembershipFreshnessInterval"/>
/// The membership is bounded by <see cref="MembershipFreshnessInterval"/>
/// (default 5 min). After that, the next hot-path authz call re-resolves LDAP group
/// memberships; failure to re-resolve (LDAP unreachable) flips the session to fail-closed
/// until a refresh succeeds.
///
/// Per decision #152 <see cref="AuthCacheMaxStaleness"/> (default 15 min) is separate from
/// <see cref="AuthCacheMaxStaleness"/> (default 15 min) is separate from
/// Phase 6.1's availability-oriented 24h cache — beyond this window the evaluator returns
/// <see cref="AuthorizationVerdict.NotGranted"/> regardless of config-cache warmth.
///
@@ -42,13 +42,13 @@ public sealed record UserAuthorizationState
/// Trie generation the session is currently bound to. When
/// <see cref="PermissionTrieCache"/> moves to a new generation, the session's
/// <c>(AuthGenerationId, MembershipVersion)</c> stamp no longer matches its
/// MonitoredItems and they re-evaluate on next publish (decision #153).
/// MonitoredItems and they re-evaluate on next publish.
/// </summary>
public required long AuthGenerationId { get; init; }
/// <summary>
/// Monotonic counter incremented every time membership is re-resolved. Combined with
/// <see cref="AuthGenerationId"/> into the subscription stamp per decision #153.
/// <see cref="AuthGenerationId"/> into the subscription stamp.
/// </summary>
public required long MembershipVersion { get; init; }
@@ -64,6 +64,7 @@ public sealed record UserAuthorizationState
/// whenever this is true.
/// </summary>
/// <param name="utcNow">The current UTC time.</param>
/// <returns><see langword="true"/> if the cached membership has exceeded the staleness ceiling.</returns>
public bool IsStale(DateTime utcNow) => utcNow - MembershipResolvedUtc > AuthCacheMaxStaleness;
/// <summary>
@@ -72,6 +73,7 @@ public sealed record UserAuthorizationState
/// call still evaluates against the cached memberships.
/// </summary>
/// <param name="utcNow">The current UTC time.</param>
/// <returns><see langword="true"/> if membership is past its freshness interval but not yet stale, signaling an async refresh is due.</returns>
public bool NeedsRefresh(DateTime utcNow) =>
!IsStale(utcNow) && utcNow - MembershipResolvedUtc > MembershipFreshnessInterval;
}