feat(acl): anonymous becomes a normal grantable principal (#520 D3)

Anonymous sessions used to short-circuit in HandleImpersonation — "let the SDK's
default validation stand" — so they always connected and always carried no roles.
Now admission and authority are separated:

  admission  -> OpcUa:AllowAnonymous, the HOST's decision (it owns token policies)
  authority  -> the authenticator, resolving the reserved principal "anonymous"
                through the SAME grant path a key's principal goes through

So anonymous can be granted WRITE, not merely tolerated as read-only, and it
arrives at the downstream gates as the same RoleCarryingUserIdentity a key
produces — which is the real payoff: no gate carries an anonymous-shaped branch,
so there is no bypass path to get wrong.

AllowAnonymous defaults to TRUE on purpose. Defaulting it false would silently
deny every client without a key the moment this deploys, including ScadaBridge's
Data Connection Layer in another repo. Making keys mandatory is a rollout
decision, not an upgrade side effect. When false, the Anonymous token policy is
withheld so a client is refused at DISCOVERY rather than only at activation, and
activation is rejected independently in case a client attempts a token type the
endpoint never advertised.

Two fail-closed choices, both falsified:
- A failure to read anonymous's grants DENIES rather than admitting with no
  roles. Admitting is the pre-#520 behaviour, and defaulting to it exactly when
  the server cannot determine what anonymous may do stops being harmless once
  reads are gated. Making it admit reds Anonymous_denies_when_grants_cannot_be_read.
- A key may not authenticate as the reserved "anonymous" principal, or a
  credentialled client silently inherits whatever anonymous was granted.

AuthenticateAnonymousAsync is a DEFAULT interface implementation returning
admit-with-no-roles, i.e. exactly the pre-#520 behaviour, so
NullOpcUaUserAuthenticator and every test double keep working rather than being
forced to reimplement it — important because driver-only nodes run the Null one
and would otherwise black out. Note a default interface method is only reachable
through the interface-typed reference, which is how OpcUaApplicationHost holds it.

An existing test asserted the old fall-through and correctly went red; it is
rewritten to pin the new contract rather than deleted, with the reason recorded.

OpcUaServer 366, Host 42 green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 13:20:14 -04:00
parent 65fda7237f
commit dd939d3c97
10 changed files with 374 additions and 19 deletions
+23
View File
@@ -193,6 +193,29 @@ of a credential was wrong.
ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing
`Enabled` takes effect on a node only once a new artifact reaches it.
### Anonymous is a normal principal
Anonymous sessions are **admitted by default** (`OpcUa:AllowAnonymous`, default `true` — flipping the
default would silently deny every client that has no key the moment this code deploys, including
ScadaBridge). Set it to `false` to make keys mandatory: the Anonymous token policy is then not
advertised, so a client is refused at *discovery* rather than only at activation, and anonymous
activation is rejected independently in case a client attempts a token type the endpoint never offered.
When admitted, an anonymous session is **not a special case** — it authenticates as the reserved
principal `anonymous`, and `NodeAcl` rows naming it grant permissions exactly as they would a key's
principal. It can therefore be granted **write**, not merely tolerated as read-only. It arrives at the
downstream gates as the same `RoleCarryingUserIdentity` a key produces, so no gate carries an
anonymous-shaped branch and there is no bypass path to get wrong.
Two consequences worth knowing:
- `anonymous` is **reserved**: an API key may not use it as its principal, or a credentialled client
would silently inherit whatever anonymous was granted. Rejected at authentication as well as at
authoring.
- If the anonymous principal's grants **cannot be read**, the session is **denied**, not admitted with
no roles. Admitting is the pre-#520 behaviour, and defaulting to it precisely when the server cannot
determine what anonymous may do stops being harmless once reads are gated.
⚠️ **Node ACLs are still not enforced per node.** Until the enforcement stages land, a key's grants are
collapsed into the two existing server-wide role gates, and **only `Cluster`-scoped grants count** —
a narrower grant confers nothing rather than silently widening to server-wide authority. Reads,