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