feat(security): enforce node ACLs on read, browse and subscribe (#520 stage 6)
Completes data-plane enforcement behind Security:Acl:Enabled. Read (every attribute, not just Value), Browse, TranslateBrowsePath, CreateMonitoredItems, SubscribeToEvents and SetMonitoringMode now consult scoped NodeAcl grants. With the flag off nothing changes. Four things went beyond the stage as written, each because leaving it out would have made the feature look like it worked: - Browse needed ancestor visibility. The trie inherits downward, and a client reaches a deep node by browsing the nodes above it, so without this every grant narrower than the cluster was authored, shipped and unreachable. It is a separate non-inheriting marker, NOT a synthesized Browse grant on each ancestor: the additive downward walk would carry such a grant from Plant onto Plant/Siemens/dev9 and every other sibling subtree. Pinned by a test that goes through the evaluator — probing the trie helper directly passes against the broken design, because a sibling is absent from the trie either way. - Revocation had to reach LIVE subscriptions. Every other operation re-asks per request; a subscription is authorized once and then streams, and streaming is how machine consumers actually read this server — so stopping only the re-asking operations would have made revocation close to cosmetic. IDataPlaneAuthoritySource gained a Changed event. A denied item is queued BadUserAccessDenied and then DETACHED from its MonitoredNode2 fan-out, not set MonitoringMode.Disabled. Disabling discards the item's queued notifications, so the client is silenced without ever being told why. The over-the-wire test caught this; the unit-level path reports the item as stood down either way. Detaching is also reversible, so a restored grant resumes the feed with no client action. - The decision cache holds the grant WALK, not the decision. Grants and the trie are fixed for a generation; a key's ExpiresUtc passes with the clock, so caching the whole decision would let an expired key keep working until the next deploy — the stage-5 open-session hole in a subtler form. - TranslateBrowsePath is gated, without which browse filtering is decorative: a client refused a browse asks TranslateBrowsePathsToNodeIds for the same path. Found en route: UserAuthorizationState's staleness ceiling couples two clocks. MembershipResolvedUtc is stamped from DataPlaneAuthorizer's TimeProvider while IsStale is evaluated against TriePermissionEvaluator's; they are the same instance in production and diverge the moment anything injects a test clock, at which point the evaluator denies EVERYTHING with a verdict indistinguishable from "no grant matched". Stage 5's claim that these predicates cannot fire was accidentally true; they are now disabled explicitly. Also fixed: ContinuationPoint.NodeToBrowse is the manager HANDLE, not a NodeState. The declared type is object, so the wrong cast compiled cleanly and silently yielded null — skipping the browse source gate entirely. Falsified: disabling the transit marking reddens exactly the three tests that assert transit works; implementing transit as an ancestor grant reddens the sibling-leak test; skipping the fan-out detach reddens the revocation test. Full solution suite green. The stage 8 live gate has NOT run yet. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -46,12 +46,18 @@ else
|
||||
equipment" into server-wide write authority.
|
||||
<br />
|
||||
<strong>With it on:</strong> the scope is honoured, and the scoped decision <em>replaces</em> the
|
||||
server-wide role rather than adding to it. Writes, alarm commands and HistoryRead are gated; the
|
||||
alarm methods separate into <span class="mono">AlarmAcknowledge</span> /
|
||||
<span class="mono">AlarmConfirm</span> / <span class="mono">AlarmShelve</span>.
|
||||
server-wide role rather than adding to it. <strong>Every data-plane operation is gated</strong> —
|
||||
<span class="mono">Read</span>, <span class="mono">Browse</span>,
|
||||
<span class="mono">Subscribe</span>, <span class="mono">WriteOperate</span>,
|
||||
<span class="mono">HistoryRead</span> and the alarm methods, which separate into
|
||||
<span class="mono">AlarmAcknowledge</span> / <span class="mono">AlarmConfirm</span> /
|
||||
<span class="mono">AlarmShelve</span>. So a grant that omits
|
||||
<span class="mono">Read</span> denies reads, and one that omits
|
||||
<span class="mono">Browse</span> hides the node from navigation.
|
||||
<br />
|
||||
<strong>Read, Browse and Subscribe are not gated in either mode</strong>, for any admitted
|
||||
session, including anonymous.
|
||||
Granting <span class="mono">Browse</span> on a node also makes the folders <em>above</em> it
|
||||
browsable — otherwise the grant could never be navigated to — so it reveals their names.
|
||||
Revoking this grant stops any live subscription it was authorizing, on the next deployment.
|
||||
<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
|
||||
|
||||
@@ -39,13 +39,18 @@ else
|
||||
<br />
|
||||
<strong>With it on:</strong> every scope kind is honoured, and the scoped decision
|
||||
<em>replaces</em> the server-wide role rather than adding to it — so a narrow grant works, and a
|
||||
Cluster-scoped one no longer confers authority beyond that cluster. Writes, alarm commands and
|
||||
HistoryRead are gated; the alarm methods separate into
|
||||
<span class="mono">AlarmAcknowledge</span> / <span class="mono">AlarmConfirm</span> /
|
||||
<span class="mono">AlarmShelve</span>.
|
||||
Cluster-scoped one no longer confers authority beyond that cluster.
|
||||
<strong>Every data-plane operation is gated</strong> — <span class="mono">Read</span>,
|
||||
<span class="mono">Browse</span>, <span class="mono">Subscribe</span>,
|
||||
<span class="mono">WriteOperate</span>, <span class="mono">HistoryRead</span> and the alarm
|
||||
methods, which separate into <span class="mono">AlarmAcknowledge</span> /
|
||||
<span class="mono">AlarmConfirm</span> / <span class="mono">AlarmShelve</span>. A principal with
|
||||
no grant at all can connect and see nothing.
|
||||
<br />
|
||||
<strong>Read, Browse and Subscribe are not gated in either mode</strong>, for any admitted
|
||||
session, including anonymous.
|
||||
A node that merely <em>leads to</em> something you may browse stays browsable, or a narrow grant
|
||||
could never be navigated to — so granting <span class="mono">Browse</span> deep in the tree also
|
||||
reveals the names of the folders above it. Revoking a grant stops live subscriptions on the next
|
||||
deployment, not just future requests.
|
||||
<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
|
||||
|
||||
@@ -270,11 +270,48 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// </remarks>
|
||||
public IDataPlaneAuthoritySource DataPlaneAuthority
|
||||
{
|
||||
get => _dataPlaneAuthority;
|
||||
set => _dataPlaneAuthority = value ?? EmptyDataPlaneAuthoritySource.Instance;
|
||||
get => Volatile.Read(ref _dataPlaneAuthority);
|
||||
set
|
||||
{
|
||||
var next = value ?? EmptyDataPlaneAuthoritySource.Instance;
|
||||
var previous = Interlocked.Exchange(ref _dataPlaneAuthority, next);
|
||||
if (ReferenceEquals(previous, next)) return;
|
||||
|
||||
// ⚠️ The DETACH is load-bearing, for the same reason it is on IRediscoverable: this manager is
|
||||
// rebuilt across the server's lifetime while the authority source is a process singleton that
|
||||
// outlives it, so a missing -= leaks a dead node manager per rebuild AND has every stale one
|
||||
// walking its own dead item map on each deploy.
|
||||
previous.Changed -= OnAuthorityChanged;
|
||||
next.Changed += OnAuthorityChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private volatile IDataPlaneAuthoritySource _dataPlaneAuthority = EmptyDataPlaneAuthoritySource.Instance;
|
||||
// Not `volatile`: the setter swaps it with Interlocked.Exchange (which cannot take a volatile ref), and
|
||||
// the getter reads it with Volatile.Read, so both accesses are ordered explicitly.
|
||||
private IDataPlaneAuthoritySource _dataPlaneAuthority = EmptyDataPlaneAuthoritySource.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Re-authorizes live subscriptions when a deployment installs a new authorization generation.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The newly published snapshot; unused — the current state is re-read per item.</param>
|
||||
private void OnAuthorityChanged(DataPlaneAuthoritySnapshot snapshot) => RevalidateMonitoredItems();
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Detaches from the authority source. The source is a process singleton that outlives this manager,
|
||||
/// so without this a disposed manager stays subscribed and re-walks its dead item map on every
|
||||
/// subsequent deploy — the same leak shape the setter's swap guards against.
|
||||
/// </remarks>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
Volatile.Read(ref _dataPlaneAuthority).Changed -= OnAuthorityChanged;
|
||||
_liveItems.Clear();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The node-scoped ACL gate (#520 stage 5), or <c>null</c> when none is wired. Consulted by the
|
||||
@@ -2591,6 +2628,414 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// #520 stage 6 — Read / Browse / TranslateBrowsePath / subscribe gates.
|
||||
//
|
||||
// These are the request-rate seams, so every decision here goes through the authorizer's CACHED
|
||||
// entry point: one Browse of a device folder asks the same question once per child, and a client
|
||||
// reading a thousand nodes asks it a thousand times.
|
||||
//
|
||||
// The gates resolve a node purely from its NodeId — namespace index for ownership, `s=` identifier
|
||||
// for the scope — and never call GetManagerHandle. That is not only cheaper: it means a DENIED node
|
||||
// is never resolved, never read, and never touches a driver.
|
||||
//
|
||||
// ⚠️ A consequence worth knowing: a NodeId in one of our namespaces that does not exist now answers
|
||||
// BadUserAccessDenied instead of BadNodeIdUnknown while enforcement is on, because the gate runs
|
||||
// before resolution. That is the information-hiding direction — an ungranted client can no longer
|
||||
// use error codes to enumerate which nodes exist — but it does mean "access denied" is not proof
|
||||
// that the node is there.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>True when <paramref name="nodeId"/> is minted in one of this manager's two namespaces.</summary>
|
||||
/// <param name="nodeId">The node id to test.</param>
|
||||
/// <returns><see langword="true"/> when this manager owns the node's namespace.</returns>
|
||||
private bool OwnsNamespace(NodeId? nodeId) =>
|
||||
nodeId is not null
|
||||
&& (nodeId.NamespaceIndex == NamespaceIndexes[0] || nodeId.NamespaceIndex == NamespaceIndexes[1]);
|
||||
|
||||
/// <summary>Pulls the ACL identity off an SDK operation context.</summary>
|
||||
/// <param name="context">The SDK context carrying the session identity.</param>
|
||||
/// <returns>The role-carrying identity, or null when the session has none.</returns>
|
||||
private static RoleCarryingUserIdentity? IdentityOf(OperationContext context) =>
|
||||
context.UserIdentity as RoleCarryingUserIdentity;
|
||||
|
||||
/// <summary>Pulls the ACL identity off a system context (the protected SDK seams).</summary>
|
||||
/// <param name="context">The SDK system context.</param>
|
||||
/// <returns>The role-carrying identity, or null when the session has none.</returns>
|
||||
private static RoleCarryingUserIdentity? IdentityOf(ServerSystemContext context) =>
|
||||
(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
|
||||
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
|
||||
|
||||
/// <summary>Whether the session may perform <paramref name="operation"/> on <paramref name="nodeId"/>.</summary>
|
||||
/// <param name="identity">The calling session's identity.</param>
|
||||
/// <param name="operation">The operation being attempted.</param>
|
||||
/// <param name="nodeId">The target node.</param>
|
||||
/// <returns><see langword="true"/> when permitted, or when the node is not ours to gate.</returns>
|
||||
private bool Permits(RoleCarryingUserIdentity? identity, OpcUaOperation operation, NodeId nodeId) =>
|
||||
!OwnsNamespace(nodeId)
|
||||
|| _authorizer!.IsAllowedCached(identity, operation, nodeId, RealmOf(nodeId));
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Denied slots are answered and marked <see cref="ReadValueId.Processed"/> <b>before</b> the base
|
||||
/// runs, so a refused read never resolves the node or samples its value. Every attribute of a node
|
||||
/// is gated by the single <c>Read</c> permission rather than only <c>Value</c>: the finer split
|
||||
/// would let an ungranted session harvest BrowseNames, DataTypes and descriptions of nodes it
|
||||
/// cannot read, which is most of what the address space discloses.
|
||||
/// </remarks>
|
||||
public override void Read(
|
||||
OperationContext context,
|
||||
double maxAge,
|
||||
IList<ReadValueId> nodesToRead,
|
||||
IList<DataValue> values,
|
||||
IList<ServiceResult> errors)
|
||||
{
|
||||
if (AclEnforced)
|
||||
{
|
||||
var identity = IdentityOf(context);
|
||||
var denied = 0;
|
||||
|
||||
for (var ii = 0; ii < nodesToRead.Count; ii++)
|
||||
{
|
||||
var nodeToRead = nodesToRead[ii];
|
||||
if (nodeToRead.Processed || !OwnsNamespace(nodeToRead.NodeId)) continue;
|
||||
if (Permits(identity, OpcUaOperation.Read, nodeToRead.NodeId)) continue;
|
||||
|
||||
nodeToRead.Processed = true;
|
||||
errors[ii] = StatusCodes.BadUserAccessDenied;
|
||||
values[ii] = new DataValue(StatusCodes.BadUserAccessDenied);
|
||||
denied++;
|
||||
}
|
||||
|
||||
LogAclFiltering("Read", identity, denied, nodesToRead.Count);
|
||||
}
|
||||
|
||||
base.Read(context, maxAge, nodesToRead, values, errors);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two distinct checks: the browsed node itself must be reachable, and every reference the base
|
||||
/// returns is filtered against the target's own grant. Filtering only the source would list
|
||||
/// children the session may not see; refusing only the source would make every narrow grant
|
||||
/// unreachable, since a client arrives at a deep node by browsing its ancestors.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reachability comes from <c>PermissionTrie.HasBrowseTransit</c> — a node is browsable when it
|
||||
/// merely leads somewhere the principal may browse. Note what this necessarily discloses: the
|
||||
/// <em>names</em> of the folders on the path to a granted node are visible to anyone holding
|
||||
/// that grant, because navigation requires them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ The two namespace root folders are referenced from the ns=0 <c>Objects</c> folder, whose
|
||||
/// browse is served by the SDK's own node manager and is not gated here. A session with no
|
||||
/// grants at all therefore still sees that the two roots exist, and is refused on entry.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public override void Browse(
|
||||
OperationContext context,
|
||||
ref ContinuationPoint continuationPoint,
|
||||
IList<ReferenceDescription> references)
|
||||
{
|
||||
if (!AclEnforced)
|
||||
{
|
||||
base.Browse(context, ref continuationPoint, references);
|
||||
return;
|
||||
}
|
||||
|
||||
var identity = IdentityOf(context);
|
||||
// NodeToBrowse holds the MANAGER HANDLE the master node manager resolved — a NodeHandle for this
|
||||
// manager — not a NodeState. The declared type is `object`, so casting it to the wrong thing
|
||||
// compiles cleanly and silently yields null, which would skip the source gate entirely and leave
|
||||
// only the reference filtering below.
|
||||
var source = (continuationPoint?.NodeToBrowse as NodeHandle)?.NodeId;
|
||||
|
||||
if (source is not null && !Permits(identity, OpcUaOperation.Browse, source))
|
||||
throw new ServiceResultException(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
var before = references.Count;
|
||||
base.Browse(context, ref continuationPoint, references);
|
||||
|
||||
var filtered = 0;
|
||||
for (var ii = references.Count - 1; ii >= before; ii--)
|
||||
{
|
||||
// ExpandedNodeId → NodeId only makes sense for a reference into our own server; a reference
|
||||
// to another server's node is left alone, as this manager has no grants that could speak to it.
|
||||
var target = references[ii].NodeId;
|
||||
if (target is null || target.IsAbsolute) continue;
|
||||
|
||||
var targetId = ExpandedNodeId.ToNodeId(target, Server.NamespaceUris);
|
||||
if (targetId is null || !OwnsNamespace(targetId)) continue;
|
||||
if (Permits(identity, OpcUaOperation.Browse, targetId)) continue;
|
||||
|
||||
references.RemoveAt(ii);
|
||||
filtered++;
|
||||
}
|
||||
|
||||
LogAclFiltering("Browse", identity, filtered, references.Count + filtered);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Gated for the same reason Browse is, and it is not optional: without it a client denied a browse
|
||||
/// simply asks <c>TranslateBrowsePathsToNodeIds</c> for the same path and gets the NodeId back, which
|
||||
/// would make the reference filtering above decorative.
|
||||
/// </remarks>
|
||||
public override void TranslateBrowsePath(
|
||||
OperationContext context,
|
||||
object sourceHandle,
|
||||
RelativePathElement relativePath,
|
||||
IList<ExpandedNodeId> targetIds,
|
||||
IList<NodeId> unresolvedTargetIds)
|
||||
{
|
||||
if (!AclEnforced)
|
||||
{
|
||||
base.TranslateBrowsePath(context, sourceHandle, relativePath, targetIds, unresolvedTargetIds);
|
||||
return;
|
||||
}
|
||||
|
||||
var identity = IdentityOf(context);
|
||||
|
||||
if (sourceHandle is NodeHandle handle
|
||||
&& !Permits(identity, OpcUaOperation.Browse, handle.NodeId))
|
||||
{
|
||||
// Silence rather than an error: the SDK's contract here is "add what you resolved", and a
|
||||
// path that resolves to nothing is the same answer the client gets for a path that does not
|
||||
// exist — which is the answer we want an ungranted session to receive.
|
||||
return;
|
||||
}
|
||||
|
||||
var before = targetIds.Count;
|
||||
base.TranslateBrowsePath(context, sourceHandle, relativePath, targetIds, unresolvedTargetIds);
|
||||
|
||||
for (var ii = targetIds.Count - 1; ii >= before; ii--)
|
||||
{
|
||||
var targetId = ExpandedNodeId.ToNodeId(targetIds[ii], Server.NamespaceUris);
|
||||
if (targetId is null || !OwnsNamespace(targetId)) continue;
|
||||
if (Permits(identity, OpcUaOperation.Browse, targetId)) continue;
|
||||
|
||||
targetIds.RemoveAt(ii);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Per item, not per batch — OPC UA Part 4 gives each item its own result, and a client subscribing
|
||||
/// to ten nodes of which one is off-limits should get nine subscriptions and one denial.
|
||||
/// </remarks>
|
||||
public override void CreateMonitoredItems(
|
||||
OperationContext context,
|
||||
uint subscriptionId,
|
||||
double publishingInterval,
|
||||
TimestampsToReturn timestampsToReturn,
|
||||
IList<MonitoredItemCreateRequest> itemsToCreate,
|
||||
IList<ServiceResult> errors,
|
||||
IList<MonitoringFilterResult> filterErrors,
|
||||
IList<IMonitoredItem> monitoredItems,
|
||||
bool createDurable,
|
||||
MonitoredItemIdFactory monitoredItemIdFactory)
|
||||
{
|
||||
if (AclEnforced)
|
||||
{
|
||||
var identity = IdentityOf(context);
|
||||
var denied = 0;
|
||||
|
||||
for (var ii = 0; ii < itemsToCreate.Count; ii++)
|
||||
{
|
||||
var item = itemsToCreate[ii];
|
||||
var target = item.ItemToMonitor?.NodeId;
|
||||
if (item.Processed || !OwnsNamespace(target)) continue;
|
||||
if (Permits(identity, OpcUaOperation.CreateMonitoredItems, target!)) continue;
|
||||
|
||||
item.Processed = true;
|
||||
errors[ii] = StatusCodes.BadUserAccessDenied;
|
||||
denied++;
|
||||
}
|
||||
|
||||
LogAclFiltering("CreateMonitoredItems", identity, denied, itemsToCreate.Count);
|
||||
}
|
||||
|
||||
base.CreateMonitoredItems(
|
||||
context, subscriptionId, publishingInterval, timestampsToReturn, itemsToCreate, errors,
|
||||
filterErrors, monitoredItems, createDurable, monitoredItemIdFactory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Event subscriptions are gated on the notifier they attach to. This is the alarm-delivery path:
|
||||
/// without it, a session denied every value on an equipment could still subscribe to its event
|
||||
/// notifier and receive every alarm the equipment raises, including the condition fields.
|
||||
/// </remarks>
|
||||
public override ServiceResult SubscribeToEvents(
|
||||
OperationContext context,
|
||||
object sourceId,
|
||||
uint subscriptionId,
|
||||
IEventMonitoredItem monitoredItem,
|
||||
bool unsubscribe)
|
||||
{
|
||||
// Only creation is gated. Refusing an UNSUBSCRIBE would strand the item on a node the session can
|
||||
// no longer reach — the client could never detach it, and the server would keep delivering.
|
||||
if (AclEnforced && !unsubscribe && sourceId is NodeState source
|
||||
&& !Permits(IdentityOf(context), OpcUaOperation.CreateMonitoredItems, source.NodeId))
|
||||
{
|
||||
return StatusCodes.BadUserAccessDenied;
|
||||
}
|
||||
|
||||
return base.SubscribeToEvents(context, sourceId, subscriptionId, monitoredItem, unsubscribe);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Re-checked on every mode change so a client cannot re-enable an item that
|
||||
/// <see cref="RevalidateMonitoredItems"/> disabled when its grant was revoked. Disabling is always
|
||||
/// allowed: a session that has lost its grant must still be able to stand its items down.
|
||||
/// </remarks>
|
||||
protected override ServiceResult SetMonitoringMode(
|
||||
ServerSystemContext context,
|
||||
IMonitoredItem monitoredItem,
|
||||
MonitoringMode monitoringMode,
|
||||
NodeHandle handle)
|
||||
{
|
||||
if (AclEnforced && monitoringMode != MonitoringMode.Disabled
|
||||
&& !Permits(IdentityOf(context), OpcUaOperation.CreateMonitoredItems, handle.NodeId))
|
||||
{
|
||||
return StatusCodes.BadUserAccessDenied;
|
||||
}
|
||||
|
||||
return base.SetMonitoringMode(context, monitoredItem, monitoringMode, handle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnMonitoredItemCreated(
|
||||
ServerSystemContext context, NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
|
||||
{
|
||||
base.OnMonitoredItemCreated(context, handle, monitoredItem);
|
||||
if (OwnsNamespace(handle.NodeId)) _liveItems[monitoredItem] = new LiveItem(handle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnMonitoredItemDeleted(
|
||||
ServerSystemContext context, NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
|
||||
{
|
||||
_liveItems.TryRemove(monitoredItem, out _);
|
||||
base.OnMonitoredItemDeleted(context, handle, monitoredItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live data-change monitored items on nodes this manager owns, so a grant revoked by a deployment
|
||||
/// can reach the subscriptions it was authorizing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keyed by the item itself; the value is the node it monitors. Maintained by
|
||||
/// <see cref="OnMonitoredItemCreated"/> / <see cref="OnMonitoredItemDeleted"/>, which the SDK calls
|
||||
/// for every create and delete including subscription teardown, so the map cannot outlive the items.
|
||||
/// </remarks>
|
||||
private readonly ConcurrentDictionary<ISampledDataChangeMonitoredItem, LiveItem> _liveItems = new();
|
||||
|
||||
/// <summary>A tracked monitored item: the handle it was created against, and whether we have silenced it.</summary>
|
||||
/// <param name="Handle">The node handle, which carries the <see cref="MonitoredNode2"/> fan-out list.</param>
|
||||
private sealed record LiveItem(NodeHandle Handle)
|
||||
{
|
||||
/// <summary>True while the item has been detached from its node's fan-out for want of a grant.</summary>
|
||||
public bool Suppressed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-authorizes every live monitored item against the current generation, standing down the ones
|
||||
/// whose grant has gone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this is not optional.</b> Stage 5 made revocation reach an open session for write,
|
||||
/// alarm commands and HistoryRead, because each of those re-asks on every request. A
|
||||
/// subscription never re-asks: it is authorized once at creation and then streams. Subscriptions
|
||||
/// are also how this server is actually consumed — ScadaBridge's Data Connection Layer
|
||||
/// subscribes rather than polls — so without this, revoking a grant would stop the operations
|
||||
/// nobody uses and leave the data flowing on the one everybody does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A denied item is <b>told, then detached</b>: queued a <c>BadUserAccessDenied</c> so the client
|
||||
/// learns why its data stopped rather than watching a feed go quiet, then removed from its node's
|
||||
/// <see cref="MonitoredNode2"/> fan-out so no further value reaches it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Detaching, not <see cref="MonitoringMode.Disabled"/> — and the order matters.</b> The
|
||||
/// obvious implementation is to queue the denial and then disable the item; it does not work,
|
||||
/// and it fails in the quiet direction. Disabling an item <em>discards its queued notifications</em>,
|
||||
/// so the client is silenced without ever being told why. This was caught by the over-the-wire
|
||||
/// test, not by reasoning: the unit-level path reports the item as stood down either way.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Detaching is also reversible, which disabling is not from the server's side. A later
|
||||
/// deployment that restores the grant re-attaches the item and the feed resumes on its own — a
|
||||
/// disabled item would have needed the client to notice and re-enable it, which a machine
|
||||
/// consumer will never do.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Items are <b>not deleted</b>. A node manager has no way to remove an item from a client's
|
||||
/// subscription, and inventing one would leave the client's own bookkeeping describing items the
|
||||
/// server had silently discarded.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public void RevalidateMonitoredItems()
|
||||
{
|
||||
if (!AclEnforced || _liveItems.IsEmpty) return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var stoodDown = 0;
|
||||
var restored = 0;
|
||||
|
||||
foreach (var (item, tracked) in _liveItems)
|
||||
{
|
||||
// The item carries the identity of the session that created it, which is what must be
|
||||
// re-checked — the deploy thread running this has no session of its own.
|
||||
if (item is not IMonitoredItem live) continue;
|
||||
|
||||
var nodeId = tracked.Handle.NodeId;
|
||||
var permitted = _authorizer!.IsAllowedCached(
|
||||
live.EffectiveIdentity as RoleCarryingUserIdentity,
|
||||
OpcUaOperation.CreateMonitoredItems, nodeId, RealmOf(nodeId));
|
||||
|
||||
if (permitted)
|
||||
{
|
||||
if (!tracked.Suppressed) continue;
|
||||
|
||||
lock (Lock) tracked.Handle.MonitoredNode?.Add(item);
|
||||
tracked.Suppressed = false;
|
||||
restored++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tracked.Suppressed) continue;
|
||||
|
||||
// Queue BEFORE detaching: the notification has to be in the item's queue while the item is
|
||||
// still live, or the client is cut off with no explanation.
|
||||
item.QueueValue(
|
||||
new DataValue(StatusCodes.BadUserAccessDenied) { SourceTimestamp = now, ServerTimestamp = now },
|
||||
new ServiceResult(StatusCodes.BadUserAccessDenied),
|
||||
ignoreFilters: true);
|
||||
|
||||
lock (Lock) tracked.Handle.MonitoredNode?.Remove(item);
|
||||
tracked.Suppressed = true;
|
||||
stoodDown++;
|
||||
}
|
||||
|
||||
if (stoodDown > 0 || restored > 0)
|
||||
{
|
||||
_authorizer!.LogFiltered(
|
||||
"RevalidateSubscriptions", principal: null, stoodDown, _liveItems.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Emits the one operator-facing summary line per gated request.</summary>
|
||||
/// <param name="operation">The service being filtered.</param>
|
||||
/// <param name="identity">The calling session's identity.</param>
|
||||
/// <param name="denied">How many nodes or references were withheld.</param>
|
||||
/// <param name="total">How many were considered.</param>
|
||||
private void LogAclFiltering(string operation, RoleCarryingUserIdentity? identity, int denied, int total) =>
|
||||
_authorizer?.LogFiltered(operation, identity?.Principal, denied, total);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Phase C — OPC UA HistoryRead over historized variable nodes.
|
||||
//
|
||||
|
||||
@@ -32,15 +32,24 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
/// session's principals out of band — the principal comes from the key, and keys and grants both
|
||||
/// ride the deployment artifact — so the only real invalidation signal is the generation stamp,
|
||||
/// which a per-call construction picks up for free. The cost is one small record per write, alarm
|
||||
/// command or HistoryRead, none of which are hot. <b>The publish path is hot, and it is stage 6's
|
||||
/// problem</b>: that seam needs a per-(session, node) decision cache, not this.
|
||||
/// command or HistoryRead, none of which are hot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A consequence worth stating: because the state is rebuilt each call against the current
|
||||
/// generation, <see cref="UserAuthorizationState.IsStale"/> and
|
||||
/// <see cref="UserAuthorizationState.NeedsRefresh"/> can never fire on this path. They are
|
||||
/// vestigial here rather than load-bearing, and a reader should not conclude from their presence
|
||||
/// that a freshness timer is doing anything.
|
||||
/// Stage 6's request-rate seams — Read, Browse, subscription creation — go through
|
||||
/// <see cref="AuthorizeCached"/> instead, which memoizes the grant walk in
|
||||
/// <see cref="Decisions"/>. That is a cache of the walk, <b>not</b> of the decision: the key's
|
||||
/// revocation and expiry are re-checked on every call regardless, because expiry moves with the
|
||||
/// clock rather than with a generation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="UserAuthorizationState.IsStale"/> and
|
||||
/// <see cref="UserAuthorizationState.NeedsRefresh"/> are vestigial here rather than load-bearing,
|
||||
/// and a reader should not conclude from their presence that a freshness timer is doing anything.
|
||||
/// They are switched off <b>explicitly</b> (<c>AuthCacheMaxStaleness = TimeSpan.MaxValue</c>) rather
|
||||
/// than left to never be reached — see the comment at that line. Relying on "the stamp is always
|
||||
/// fresh" was not safe: the stamp comes from this class's clock and the ceiling is checked against
|
||||
/// the evaluator's, so any divergence between the two denied every request with a verdict that
|
||||
/// looks exactly like "no grant matched".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DataPlaneAuthorizer
|
||||
@@ -80,6 +89,72 @@ public sealed class DataPlaneAuthorizer
|
||||
/// </summary>
|
||||
public bool Enabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Memoized grant walks for the request-rate seams (#520 stage 6). See
|
||||
/// <see cref="SessionAclDecisionCache"/> for what it is and is not allowed to cache.
|
||||
/// </summary>
|
||||
public SessionAclDecisionCache Decisions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes <paramref name="operation"/> on <paramref name="nodeId"/> through the decision cache —
|
||||
/// the entry point for Read, Browse and subscription creation, which ask the same question many
|
||||
/// times per request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Identical in outcome to <see cref="Authorize"/>; it differs only in memoizing the grant walk and
|
||||
/// in logging denials at Debug rather than Information. The quieter log is deliberate: a browse of a
|
||||
/// large folder by an ungranted principal would otherwise emit one Information line per child. The
|
||||
/// per-request summary the node manager logs is the operator-facing signal; this is the detail behind
|
||||
/// it.
|
||||
/// </remarks>
|
||||
/// <param name="identity">The role-carrying identity off the SDK context, or null when the session carries none.</param>
|
||||
/// <param name="operation">The operation being attempted.</param>
|
||||
/// <param name="nodeId">The target node.</param>
|
||||
/// <param name="realm">The namespace the node was resolved in.</param>
|
||||
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
|
||||
public ServiceResult? AuthorizeCached(
|
||||
RoleCarryingUserIdentity? identity,
|
||||
OpcUaOperation operation,
|
||||
NodeId nodeId,
|
||||
AddressSpaceRealm realm)
|
||||
=> Evaluate(identity, operation, nodeId, realm, cached: true);
|
||||
|
||||
/// <summary>
|
||||
/// Emits the one operator-facing line per gated request, summarising what a hot seam withheld.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This exists because the per-node denials behind it are logged at Debug: a Browse of a large
|
||||
/// folder by an ungranted principal would otherwise emit one Information line per child, and an
|
||||
/// operator would be reading a flood rather than a fact. It lives on the authorizer rather than the
|
||||
/// node manager purely because the node manager is a <c>CustomNodeManager2</c> and carries no
|
||||
/// <c>ILogger</c> — everything it logs itself goes through the SDK's obsolete static trace.
|
||||
/// </remarks>
|
||||
/// <param name="operation">The service being filtered, e.g. <c>Browse</c>.</param>
|
||||
/// <param name="principal">The calling principal, or null when the session resolved none.</param>
|
||||
/// <param name="withheld">How many nodes or references were withheld.</param>
|
||||
/// <param name="total">How many were considered.</param>
|
||||
public void LogFiltered(string operation, string? principal, int withheld, int total)
|
||||
{
|
||||
if (withheld == 0) return;
|
||||
|
||||
_log.LogInformation(
|
||||
"AclGate: {Operation} withheld {Withheld} of {Total} node(s) for principal {Principal}",
|
||||
operation, withheld, total, principal ?? "(none)");
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="identity"/> may perform <paramref name="operation"/>, as a bool.</summary>
|
||||
/// <param name="identity">The role-carrying identity off the SDK context.</param>
|
||||
/// <param name="operation">The operation being attempted.</param>
|
||||
/// <param name="nodeId">The target node.</param>
|
||||
/// <param name="realm">The namespace the node was resolved in.</param>
|
||||
/// <returns><see langword="true"/> when the operation is permitted.</returns>
|
||||
public bool IsAllowedCached(
|
||||
RoleCarryingUserIdentity? identity,
|
||||
OpcUaOperation operation,
|
||||
NodeId nodeId,
|
||||
AddressSpaceRealm realm)
|
||||
=> AuthorizeCached(identity, operation, nodeId, realm) is null;
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes <paramref name="operation"/> on <paramref name="nodeId"/> for the session behind
|
||||
/// <paramref name="identity"/>.
|
||||
@@ -96,16 +171,24 @@ public sealed class DataPlaneAuthorizer
|
||||
OpcUaOperation operation,
|
||||
NodeId nodeId,
|
||||
AddressSpaceRealm realm)
|
||||
=> Evaluate(identity, operation, nodeId, realm, cached: false);
|
||||
|
||||
private ServiceResult? Evaluate(
|
||||
RoleCarryingUserIdentity? identity,
|
||||
OpcUaOperation operation,
|
||||
NodeId nodeId,
|
||||
AddressSpaceRealm realm,
|
||||
bool cached)
|
||||
{
|
||||
if (identity is null)
|
||||
return Deny(operation, nodeId, principal: null, "the session carries no role-carrying identity");
|
||||
return Deny(operation, nodeId, principal: null, "the session carries no role-carrying identity", cached);
|
||||
|
||||
if (string.IsNullOrEmpty(identity.Principal))
|
||||
{
|
||||
// An authenticator that predates the principal model, or a code path that built the identity
|
||||
// through the legacy two-argument constructor. Guessing a principal here — from the display
|
||||
// name, say — would silently authorize as whatever the token's username happened to be.
|
||||
return Deny(operation, nodeId, principal: null, "the session resolved no ACL principal");
|
||||
return Deny(operation, nodeId, principal: null, "the session resolved no ACL principal", cached);
|
||||
}
|
||||
|
||||
var snapshot = _authority.Current;
|
||||
@@ -113,41 +196,81 @@ public sealed class DataPlaneAuthorizer
|
||||
if (string.IsNullOrWhiteSpace(snapshot.ClusterId))
|
||||
{
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
"this node has no cluster identity, so no permission trie exists to evaluate against");
|
||||
"this node has no cluster identity, so no permission trie exists to evaluate against", cached);
|
||||
}
|
||||
|
||||
// Re-check revocation on every call. The key was verified at session activation, but a deployment
|
||||
// since then may have disabled or expired it — and on a driver-only node the artifact is the ONLY
|
||||
// revocation channel. Without this a revoked key keeps its open session's authority indefinitely,
|
||||
// which on a machine-to-machine data plane means until the client chooses to reconnect.
|
||||
// ⚠️ Deliberately OUTSIDE the decision cache, and it must stay there. Grants and principals are
|
||||
// fixed for a generation, so memoizing the grant walk is sound; a key's expiry passes with the
|
||||
// CLOCK, so a cached allow would keep an expired key working until the next deploy.
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var sessionKey = identity.ApiKeyId ?? identity.Principal;
|
||||
if (identity.ApiKeyId is { Length: > 0 } keyId && snapshot.FindUsableKey(keyId, now) is null)
|
||||
{
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
$"key {keyId} is no longer enabled or has expired in generation {snapshot.GenerationId}");
|
||||
$"key {keyId} is no longer enabled or has expired in generation {snapshot.GenerationId}", cached);
|
||||
}
|
||||
|
||||
if (!TryBuildScope(nodeId, realm, snapshot.ClusterId, out var scope))
|
||||
{
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
"the node id carries no string identifier, so it addresses no ACL scope");
|
||||
"the node id carries no string identifier, so it addresses no ACL scope", cached);
|
||||
}
|
||||
|
||||
var allowed = cached
|
||||
? Decisions.GetOrAdd(
|
||||
snapshot.GenerationId, sessionKey, operation, realm, (string)nodeId.Identifier,
|
||||
() => Walk(snapshot, identity, operation, scope, now))
|
||||
: Walk(snapshot, identity, operation, scope, now);
|
||||
|
||||
if (allowed) return null;
|
||||
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
$"no grant in generation {snapshot.GenerationId} confers {operation} here", cached);
|
||||
}
|
||||
|
||||
/// <summary>The grant walk itself — the only part of a decision that is safe to memoize.</summary>
|
||||
/// <param name="snapshot">The authority snapshot the decision is taken against.</param>
|
||||
/// <param name="identity">The calling identity.</param>
|
||||
/// <param name="operation">The operation being attempted.</param>
|
||||
/// <param name="scope">The resolved node scope.</param>
|
||||
/// <param name="now">The current time, for the authorization state's resolution stamp.</param>
|
||||
/// <returns><see langword="true"/> when a grant confers the operation.</returns>
|
||||
private bool Walk(
|
||||
DataPlaneAuthoritySnapshot snapshot,
|
||||
RoleCarryingUserIdentity identity,
|
||||
OpcUaOperation operation,
|
||||
NodeScope scope,
|
||||
DateTime now)
|
||||
{
|
||||
var session = new UserAuthorizationState
|
||||
{
|
||||
SessionId = identity.ApiKeyId ?? identity.Principal,
|
||||
ClusterId = snapshot.ClusterId,
|
||||
ClusterId = snapshot.ClusterId!,
|
||||
Principals = [identity.Principal],
|
||||
MembershipResolvedUtc = now,
|
||||
AuthGenerationId = snapshot.GenerationId,
|
||||
MembershipVersion = snapshot.GenerationId,
|
||||
|
||||
// ⚠️ Disables the LDAP-era freshness ceiling explicitly, rather than relying on it never
|
||||
// being reached. Two separate reasons, and the second is the one that bites:
|
||||
//
|
||||
// 1. It is meaningless here. The ceiling exists so a long-lived session re-resolves group
|
||||
// membership a directory changed out of band. Nothing changes out of band under API keys
|
||||
// — principals come from the key and both keys and grants ride the artifact — so a
|
||||
// "stale membership" is not a state this model can be in.
|
||||
// 2. It couples two clocks. MembershipResolvedUtc is stamped from THIS class's TimeProvider,
|
||||
// while IsStale is evaluated against the EVALUATOR's. They are the same instance in
|
||||
// production and diverge the moment anything injects a test clock — at which point the
|
||||
// evaluator silently denies everything with a verdict indistinguishable from "no grant".
|
||||
// That is how this was found: a fake clock two hours ahead made every decision NotGranted.
|
||||
AuthCacheMaxStaleness = TimeSpan.MaxValue,
|
||||
};
|
||||
|
||||
var decision = _evaluator.Authorize(session, operation, scope);
|
||||
if (decision.IsAllowed) return null;
|
||||
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
$"no grant in generation {snapshot.GenerationId} confers {operation} here");
|
||||
return _evaluator.Authorize(session, operation, scope).IsAllowed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -184,12 +307,18 @@ public sealed class DataPlaneAuthorizer
|
||||
return true;
|
||||
}
|
||||
|
||||
private ServiceResult Deny(OpcUaOperation operation, NodeId? nodeId, string? principal, string reason)
|
||||
private ServiceResult Deny(
|
||||
OpcUaOperation operation, NodeId? nodeId, string? principal, string reason, bool cached = false)
|
||||
{
|
||||
// Logged, never returned. The client gets an undifferentiated BadUserAccessDenied so the server
|
||||
// cannot be used to probe which nodes exist or which grants a principal holds; the operator
|
||||
// debugging a denial reads the reason here. Same posture as the authenticator's DenyMessage.
|
||||
_log.LogInformation(
|
||||
//
|
||||
// The hot seams log at Debug because a single Browse of an ungranted folder would otherwise emit
|
||||
// one Information line per child. Their operator-facing signal is the per-request summary the node
|
||||
// manager logs, which says how many references were filtered and for whom.
|
||||
_log.Log(
|
||||
cached ? LogLevel.Debug : LogLevel.Information,
|
||||
"AclGate: denied {Operation} on {NodeId} for principal {Principal} — {Reason}",
|
||||
operation, nodeId, principal ?? "(none)", reason);
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Memoizes the <b>grant-walk</b> half of an ACL decision per <c>(session, node, operation)</c> for one
|
||||
/// authorization generation (#520 stage 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists.</b> Stage 5 gated write, alarm commands and HistoryRead — all of which are
|
||||
/// request-rate operations where rebuilding the decision each time costs nothing. Stage 6 gates
|
||||
/// Read, Browse and subscription creation, which are not: one Browse of a device folder asks the
|
||||
/// same question once per child, and a client that reads a thousand nodes asks it a thousand times.
|
||||
/// The trie walk is cheap individually and the repetition is what makes it worth avoiding.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Only the grant walk is cached — never the key check.</b> A cached verdict is valid because
|
||||
/// grants, principals and the trie are all fixed for the life of a generation, so nothing the walk
|
||||
/// reads can change without <see cref="Generation"/> advancing. That reasoning does <b>not</b>
|
||||
/// extend to the calling key's usability: a key's <c>ExpiresUtc</c> passes with the clock, not with
|
||||
/// a deployment, so a cached allow would keep an expired key working until the next deploy —
|
||||
/// re-introducing, in a subtler form, exactly the open-session hole stage 5 closed.
|
||||
/// <see cref="DataPlaneAuthorizer"/> therefore runs revocation and expiry on every call and consults
|
||||
/// this only afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Invalidation is wholesale, by generation.</b> Per-entry invalidation would need to know which
|
||||
/// nodes a changed grant covers, which is the trie walk run backwards; dropping everything on a
|
||||
/// deploy costs one repopulation and cannot be wrong. Deployments are rare relative to reads.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SessionAclDecisionCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Entry ceiling, past which the cache clears rather than growing. Large enough that a realistic
|
||||
/// address space browsed by a handful of machine consumers never reaches it, small enough that a
|
||||
/// pathological client cannot use it to exhaust memory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Clearing beats evicting here: an LRU needs per-entry bookkeeping on the hot path to save a trie
|
||||
/// walk that is itself cheap, so the eviction policy would cost more than the miss it prevents. The
|
||||
/// failure mode of clearing is a burst of recomputation, which is exactly the steady state of not
|
||||
/// having a cache at all.
|
||||
/// </remarks>
|
||||
public const int MaxEntries = 100_000;
|
||||
|
||||
private readonly ConcurrentDictionary<DecisionKey, bool> _entries = new();
|
||||
private long _generation = -1;
|
||||
|
||||
/// <summary>The authorization generation the cached decisions were taken under.</summary>
|
||||
public long Generation => Interlocked.Read(ref _generation);
|
||||
|
||||
/// <summary>Number of decisions currently memoized. Diagnostics and tests.</summary>
|
||||
public int Count => _entries.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the memoized decision for this key, computing and storing it when absent or when the
|
||||
/// generation has moved on.
|
||||
/// </summary>
|
||||
/// <param name="generation">The authorization generation the decision is being taken under.</param>
|
||||
/// <param name="sessionKey">Stable identity of the calling session — its key id, else its principal.</param>
|
||||
/// <param name="operation">The operation being attempted.</param>
|
||||
/// <param name="realm">The realm the node was resolved in.</param>
|
||||
/// <param name="nodeIdentifier">The node's <c>s=</c> identifier.</param>
|
||||
/// <param name="compute">The grant walk to run on a miss.</param>
|
||||
/// <returns><see langword="true"/> when the operation is permitted.</returns>
|
||||
public bool GetOrAdd(
|
||||
long generation,
|
||||
string sessionKey,
|
||||
OpcUaOperation operation,
|
||||
AddressSpaceRealm realm,
|
||||
string nodeIdentifier,
|
||||
Func<bool> compute)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(compute);
|
||||
|
||||
InvalidateIfStale(generation);
|
||||
|
||||
var key = new DecisionKey(sessionKey, operation, realm, nodeIdentifier);
|
||||
if (_entries.TryGetValue(key, out var cached)) return cached;
|
||||
|
||||
var decided = compute();
|
||||
|
||||
// Re-read the generation AFTER computing. A deploy landing mid-walk means the verdict may have been
|
||||
// taken against either generation, and storing it under the new one would pin a decision the new
|
||||
// trie never made. Dropping it costs one recomputation on the next call.
|
||||
if (Generation != generation) return decided;
|
||||
|
||||
if (_entries.Count >= MaxEntries) _entries.Clear();
|
||||
_entries[key] = decided;
|
||||
return decided;
|
||||
}
|
||||
|
||||
/// <summary>Drops every memoized decision. Called when the authorization generation advances.</summary>
|
||||
public void Clear() => _entries.Clear();
|
||||
|
||||
private void InvalidateIfStale(long generation)
|
||||
{
|
||||
var seen = Interlocked.Read(ref _generation);
|
||||
if (seen == generation) return;
|
||||
|
||||
// Only the thread that actually moves the stamp clears, so a burst of concurrent requests arriving
|
||||
// just after a deploy repopulates once rather than each clearing the others' fresh entries.
|
||||
if (Interlocked.CompareExchange(ref _generation, generation, seen) == seen) _entries.Clear();
|
||||
}
|
||||
|
||||
private readonly record struct DecisionKey(
|
||||
string SessionKey, OpcUaOperation Operation, AddressSpaceRealm Realm, string NodeIdentifier);
|
||||
}
|
||||
Reference in New Issue
Block a user