fix(acl): filter Server-object (i=2253) event subscriptions per event through the data-plane ACL (#586)

Server-object (all-events) items bypassed the ACL entirely: the SDK attached them to every root
notifier's fan-out via SubscribeToAllEvents, and a root notifier with no direct subscriber forwarded
to the core Server object, which this node manager never sees.

- SubscribeToAllEvents override records the item and attaches it to nothing.
- Each condition's own OnReportEvent (fires once per event) queues the event to each recorded item
  whose session holds Subscribe on the condition (RawPath) or any notifier folder it reports through
  -- the per-folder path's question; otherwise mirrors MonitoredNode2.OnReportEvent.
- The root-notifier forward to the core Server object (OnReportEvent override) is dropped.
- ConditionRefresh on an i=2253 item is filtered the same way (once per condition).
- Revalidation is implicit: every event re-asks the authorizer (per-generation cache, key expiry and
  revocation re-checked per call). Inert with Security:Acl:Enabled=false.

Over-the-wire tests: no-grant vs granted (positive control), created-before-deploy, revoke/restore by
deployment, key expiry with no tick, refresh, and flag-off. Falsified: disabling the filter, or
restoring the forward, turns the enforcement tests red.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 23:37:47 -04:00
parent 35152fcaf7
commit 75e559f6d8
5 changed files with 655 additions and 30 deletions
+35 -10
View File
@@ -531,16 +531,41 @@ Behaviours worth knowing before enabling it:
- **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.
**Server-object (`i=2253`, all-events) event subscriptions are filtered per event at delivery** (Gitea
#586). They used to be outside the ACL entirely: the SDK attached such an item to the fan-out of every
one of our root notifiers through `SubscribeToAllEvents`, and an equipment folder with no direct
subscriber forwarded its events to the core node manager's Server object, which our node manager never
sees — so a session holding no grant received every alarm in the server. Now:
- The node manager attaches a Server-object item to **nothing**. It records it, and each condition hands
its events to the recorded items itself (the condition's own `OnReportEvent`, which fires exactly once
per event, so the Server object still sees one copy). The root-notifier forward to the core Server
object is dropped.
- Each event is delivered to an item only if the item's session holds `Subscribe` on **any** of: the
condition itself (`ConditionId` = RawPath, raw realm — so a grant on the alarm's raw tag admits it), a
root-notifier folder the condition bubbles through (its raw device folder; a scripted alarm's
equipment folder), or an equipment folder wired as its extra notifier. That is the per-folder path's
question — a session that may subscribe to any notifier the alarm reports through already receives it
there — asked with the same authorizer and the same operation as the per-folder creation gate. The
folders must be asked as well as the condition because the trie inherits downward only within a realm:
an `Equipment` grant covers the UNS folder, not the condition at its RawPath.
- **Revalidation is implicit, not trigger-driven.** Every event re-asks the authorizer, whose decision
cache is keyed per generation and which re-checks key revocation and expiry on every call — so a
deployment takes effect on the next event, and an expired key's feed stops on the next event, without
waiting for the `AclRevalidationTimer` tick. Nothing is detached, so restoring a grant needs no
re-attach: the next event simply passes. Events raised while withheld are not replayed, and the client
is not told (the same reasoning as a stood-down direct item).
- Creation is **not** refused — an all-events item is legitimate for a partially-granted session; what
is withheld is each event it may not see.
- **`ConditionRefresh` on a Server-object item** replays only the conditions the session passes the same
check on (once per condition, not once per notifier it is wired to). Without that, a refresh would
replay every condition's current state to any session.
- Inert with the flag off: every Server-object item receives every condition, as before.
Residual, not closed by #586: **audit and model-change events** (`AuditWriteUpdateEvent`,
`GeneralModelChangeEvent`, …) are still reported straight to the Server object and reach every
Server-object item ungated. They are delivered only when auditing / model-change reporting is on (and
audit events only over an encrypted channel), and a node manager has no seam on the core Server
object's fan-out to filter them.
- **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