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
+45 -3
View File
@@ -406,6 +406,27 @@ Both gates fail closed — a session with no identity or no matching role gets
the constants in `OpcUaDataPlaneRoles`, derived from the principal's cluster-scoped `NodeAcl` grants
(see [Role grant source](#role-grant-source-data-plane)).
**Both gates also re-check the session's API key on every call, flag off or on** (Gitea #563). A
session's roles are resolved once, at activation, so before this an open session kept `WriteOperate` /
`AlarmAck` after its key's `ExpiresUtc` passed — or after a deployment disabled or removed the key —
until the client chose to disconnect, which on a machine-to-machine data plane can be never. Now each
write and each Part 9 method call first asks `DataPlaneAuthorizer.AuthorizeKeyUsable`: the same
`FindUsableKey` probe, against the same deployed snapshot and the same clock, that the ACL path makes
(one dictionary lookup, no I/O). An unusable key gets `BadUserAccessDenied` (logged as
`AclGate: denied … key … is no longer enabled or has expired`), checked *before* the "no gateway ⇒
`BadNotWritable`" availability answer. Things to know:
- **Only these two gates.** Read, Browse, Subscribe and HistoryRead stay ungated with the flag off —
that is the flag-off posture, and an expired key's session can still read and keep its subscriptions.
Turn `Security:Acl:Enabled` on to have expiry reach those too.
- **Anonymous is unaffected.** It carries no key id, so there is nothing to expire; its roles stay what
activation resolved from the `anonymous` principal's grants.
- **No flag.** An expired or disabled key keeping write authority is a defect, not a rollout choice.
- ⚠️ **On a fused admin+driver node a key minted after the last deployment authenticates (the ConfigDb
store is read at activation) but its writes and acks are refused until a deployment carries it** — the
check reads the deployed snapshot, which is also what the flag-on path has always done. Deploy after
minting a key.
**What has no authorization check at all:**
- **Read, Browse, TranslateBrowsePaths** — no override, no handler. Any admitted session, including
@@ -496,9 +517,30 @@ Behaviours worth knowing before enabling it:
no reconnect** — closing the session instead would force a full reconnect-and-resubscribe on every
machine consumer in the fleet at once when a shared key expired.
⚠️ **Event/alarm monitored items are revalidated by neither trigger.** The live-item map is fed only
from data-change items, so an alarm subscription is gated at creation and never again. Pre-existing —
the deployment trigger has the same hole — and tracked as a residual of arch-review 03/S19.
**Event/alarm monitored items are revalidated by both triggers too** (Gitea #562). A direct event
subscription — one on an equipment or device folder's event notifier — is tracked in a second map
alongside the data-change items and re-asked the creation gate's question (`Subscribe` on the
notifier's scope, for the identity of the session that created it). A denied item is **detached** from
the notifier's event fan-out through the SDK's own unsubscribe — exactly what the client's delete would
do — never set `MonitoringMode.Disabled`, so restoring its authority re-attaches it and alarms resume
with no client action. Events raised while it was stood down are not replayed. Two differences from a
data-change item:
- **The client is not told.** An event item has no value slot to carry `BadUserAccessDenied`, and a
fabricated "access denied" event would land in every alarm consumer's stream, so the feed simply goes
quiet. The operator-facing signal is the `AclGate: RevalidateEventSubscriptions withheld N of M` log line.
- **A client may delete a stood-down item** normally; the delete is absorbed (the item is already off
the fan-out) and a later restoring deployment does not resurrect it.
⚠️ **Server-object (all-events) event subscriptions are outside the ACL entirely — at creation and at
revalidation.** A subscription to `i=2253` (the Server object) is fanned out by the SDK through
`SubscribeToAllEvents` and through the Server object's own notifier, which the core node manager owns —
our node manager's creation gate never sees it, and an equipment folder with no direct subscriber
forwards its events to the Server object regardless of what our node manager attaches. So with the flag
on, a session subscribed to the Server object still receives every alarm in the address space (observed
over the wire 2026-10-07: an anonymous session holding no grant at all received a native alarm through a
Server-object event item). Scope
alarm consumers by subscribing to the equipment/device folders, and treat Server-object event access as
ungated until this is closed.
- **An ancestor of a granted node is browsable**, or no narrow grant would be reachable: a client
arrives at a deep node by browsing its ancestors, and the trie otherwise inherits downward only. Note
what this necessarily discloses — the *names* of the folders on the path to a granted node are visible