fix(acl): revalidate event/alarm subscriptions on both triggers; key-usability check on role gates (#562, #563)

#562: direct event (alarm) monitored items are now tracked in a second map
(_eventItems, fed by the SubscribeToEvents override) and re-authorized by
RevalidateMonitoredItems on both triggers (DataPlaneAuthorityState.Changed and
AclRevalidationTimer). A denied item is detached via the SDK's own protected
SubscribeToEvents(unsubscribe: true) - never MonitoringMode.Disabled - and
re-attached on restore; a client delete of a stood-down item is absorbed.
Over-the-wire tests: revoke -> stop (positive control on a still-granted
notifier) -> restore -> resume; clock-driven expiry via the real timer with a
control session; delete-while-stood-down is not resurrected; unexpired
negative control. Server-object (all-events) subscriptions are documented as
outside the ACL (observed over the wire).

#563: DataPlaneAuthorizer.AuthorizeKeyUsable runs the FindUsableKey probe on
the WriteOperate and AlarmAck role gates regardless of Security:Acl:Enabled,
so an expired or deployment-disabled key loses its activation-time roles on
its open session. Anonymous (no key id) is unaffected; reads/subscriptions
stay ungated with the flag off. No new flag.

Docs: docs/security.md and the CLAUDE.md API-key/ACL section.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 23:10:35 -04:00
parent 04b6563286
commit 2b945672e9
7 changed files with 1141 additions and 9 deletions
@@ -201,6 +201,44 @@ public sealed class DataPlaneAuthorizer
AddressSpaceRealm realm)
=> Evaluate(identity, operation, nodeId, realm, cached: false);
/// <summary>
/// Whether the API key behind <paramref name="identity"/> is still usable — consulted by the two
/// server-wide role gates (<c>WriteOperate</c>, <c>AlarmAck</c>) <b>regardless of
/// <see cref="Enabled"/></b> (Gitea #563).
/// </summary>
/// <remarks>
/// <para>
/// With the flag off a session's roles are resolved once, at activation, so before this check a
/// key that expired by the clock — or that a deployment disabled or removed — kept
/// <c>WriteOperate</c> / <c>AlarmAck</c> on its open session until the client disconnected. With
/// the flag on, <see cref="Authorize"/> already makes the identical check first thing, so the role
/// gates call this only on the flag-off path and the two postures agree on key usability.
/// </para>
/// <para>
/// It is the same <see cref="DataPlaneAuthoritySnapshot.FindUsableKey"/> probe against the same
/// snapshot and the same clock as <see cref="Authorize"/>: one dictionary lookup, no I/O, so it is
/// safe on the SDK's synchronous write and method-call threads. A session carrying <b>no</b> key id —
/// Anonymous, or a legacy identity — has nothing that can expire and always passes; its roles stay
/// exactly what activation resolved. A key absent from the snapshot denies, as it does with the flag
/// on: on a driver-only node the artifact is the only key source there is, so "absent" means
/// "removed by a deployment".
/// </para>
/// </remarks>
/// <param name="identity">The role-carrying identity off the SDK context, or null.</param>
/// <param name="operation">The operation being attempted, for the denial log.</param>
/// <param name="nodeId">The target node, for the denial log.</param>
/// <returns><c>null</c> to proceed to the role check; otherwise the veto to return to the SDK.</returns>
public ServiceResult? AuthorizeKeyUsable(
RoleCarryingUserIdentity? identity, OpcUaOperation operation, NodeId nodeId)
{
if (identity?.ApiKeyId is not { Length: > 0 } keyId) return null;
var snapshot = _authority.Current;
return snapshot.FindUsableKey(keyId, _time.GetUtcNow().UtcDateTime) is null
? DenyRevokedKey(operation, nodeId, identity.Principal, keyId, snapshot.GenerationId, cached: false)
: null;
}
/// <summary>
/// The decision itself. Ordered so the cached seams reach their answer having allocated nothing:
/// every check that must run per call comes first, the memoized verdict is consulted next, and the