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:
@@ -859,6 +859,15 @@ Two things to know before relying on any of this:
|
||||
browse, subscribe and HistoryRead are ungated for any admitted session including Anonymous**. With the
|
||||
flag **on** (`DataPlaneAuthorizer`), scoped grants decide **every** data-plane operation: write and
|
||||
alarm commands and HistoryRead (stage 5), read and browse and subscription creation (stage 6).
|
||||
- **The two role gates re-check the session's KEY on every call, flag off or on (Gitea #563).** Roles
|
||||
are resolved once at activation, so an expired / deployment-disabled key used to keep `WriteOperate` /
|
||||
`AlarmAck` on its open session until the client disconnected. `DataPlaneAuthorizer.AuthorizeKeyUsable`
|
||||
runs the same `FindUsableKey` probe against the same snapshot + clock as the ACL path (one dictionary
|
||||
lookup, no I/O) and denies `BadUserAccessDenied` before the role check. **Only those two gates** —
|
||||
reads/subscriptions stay ungated with the flag off, by design. Anonymous carries no key and is
|
||||
unaffected. No flag: a dead key keeping write authority is a defect, not a rollout choice. ⚠️ On a
|
||||
fused node a key minted after the last deploy authenticates (ConfigDb) but cannot write/ack until a
|
||||
deployment carries it — the flag-on path always behaved this way.
|
||||
- ⚠️ **The ACL decision REPLACES the role check — the two are never chained.** Chained, a grant would
|
||||
have to confer the server-wide role *as well as* pass the scope check, and only `Cluster`-scoped
|
||||
grants confer a role, so every narrow grant would stay inert and the flag would appear to do
|
||||
@@ -910,6 +919,15 @@ Two things to know before relying on any of this:
|
||||
`Security:Acl:Enabled` is false), runs the same pass on the clock — because `ExpiresUtc` passes with
|
||||
the clock, not with a deployment. The pass's verdict memo is **pass-local and must stay that way**;
|
||||
hoisting it across ticks would serve an expiry that landed between two ticks the earlier tick's allow.
|
||||
**Event/alarm items ride both triggers too (Gitea #562)** — a second map (`_eventItems`), fed by the
|
||||
`SubscribeToEvents` override, holds every *direct* notifier subscription; a denied one is detached via
|
||||
the SDK's own protected `SubscribeToEvents(…, unsubscribe: true)` (what a client delete does) and
|
||||
re-attached with `unsubscribe: false` on restore — again never `Disabled`. Unlike a data item it is
|
||||
**not told** (no value slot for a status; the feed goes quiet), and a client delete of a stood-down
|
||||
item is absorbed by the override (the SDK would otherwise remove an item that is not attached).
|
||||
⚠️ **Server-object (`i=2253`) event subscriptions are outside the ACL entirely** — fanned out by
|
||||
`SubscribeToAllEvents` + the core node manager's Server notifier, which our creation gate never sees;
|
||||
live-observed: a grantless anonymous session receives every alarm through one. Open residual.
|
||||
- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`,
|
||||
preserving today's behaviour) controls *admission*; when admitted the session authenticates as the
|
||||
reserved principal `anonymous` and gets roles from `NodeAcl` rows naming it, arriving at the gates as
|
||||
@@ -932,8 +950,9 @@ Two things to know before relying on any of this:
|
||||
**Expiry is the exception**: `FindUsableKey` compares `ExpiresUtc` against the authorizer's clock on every
|
||||
call, so it is immediate everywhere — and since arch-review 03/S19 it reaches live subscriptions too, on
|
||||
the host's `Security:Acl:RevalidationIntervalSeconds` timer (default 60 s; the session is **silenced with
|
||||
`BadUserAccessDenied`, not closed**, and recovers on re-activation). ⚠️ Event/alarm monitored items are
|
||||
revalidated by neither trigger — pre-existing, and a named residual.
|
||||
`BadUserAccessDenied`, not closed**, and recovers on re-activation). Direct event/alarm subscriptions
|
||||
are revalidated on both triggers as well (#562); with the flag **off**, expiry and revocation reach the
|
||||
two role gates (#563) but not reads or subscriptions.
|
||||
- ⚠️ **The activation lookup is BOUNDED (arch-review 03/S18).** The SDK raises impersonation synchronously
|
||||
under a `SessionManager`-wide lock, so a ConfigDb that is *slow* rather than *refusing* stalled **every**
|
||||
session activation on the server, Anonymous and X.509 included. `BoundedApiKeyStore` wraps the ConfigDb
|
||||
|
||||
Reference in New Issue
Block a user