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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user