merge: node ACL enforcement on write, alarms and HistoryRead (#520 stage 5)
Behind Security:Acl:Enabled (default false), scoped NodeAcl grants now decide writes, alarm commands and HistoryRead. HistoryRead had no authorization check of any kind before this. Read, Browse and Subscribe stay ungated in both modes (stage 6). The ACL decision REPLACES the role check rather than chaining with it. 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 nothing. The consequence for tests: a deny-only test is vacuous here because the role gate denies too, so the load-bearing test is an allow — a roleless session writing on its grant alone. Fixes a live defect found on the way in: the ACL principal never reached the session. RoleCarryingUserIdentity derived DisplayName from the presented token, which is the static username "apikey" for every key-bearing client, so the trie's matching key was dropped at the identity boundary and every rejected write audited as "apikey". Beyond the stage as written: - revocation now reaches an already-open session (both a revoked key and a removed grant), rather than persisting until the client chooses to reconnect - UserAuthorizationState is built per call, not per session; its freshness predicates are LDAP-shaped and cannot fire under API keys, so they are documented as vestigial rather than left looking load-bearing - the trie is built inside DataPlaneAuthorityState.Apply, not as a second wiring — an uninstalled trie denies everything, indistinguishable from working enforcement - both IApiKeyStore implementations widened from Cluster-scoped grants to the full grant set; ApiKeyInterimRoleMapper still narrows, pinned by tests The stage 8 live gate has NOT run — all verification is offline. Every failure path denies, so a wiring mistake presents as a total outage of the three gated operations rather than a subtle permission bug. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -610,12 +610,32 @@ the data-plane authenticator when you see LDAP wiring in `Program.cs` — it is
|
||||
|
||||
Two things to know before relying on any of this:
|
||||
|
||||
- ⚠️ **Node ACLs are still not enforced per node.** A key's grants collapse into the two existing
|
||||
server-wide role gates (`WriteOperate` on Value writes, `AlarmAck` on Part 9 methods), and
|
||||
**only `Cluster`-scoped grants count** — `ApiKeyInterimRoleMapper` deliberately under-grants, because
|
||||
honouring a narrower grant's flags while unable to honour its *scope* would turn "WriteOperate on
|
||||
Equipment X" into server-wide write authority. Read, Browse, HistoryRead and CreateMonitoredItems
|
||||
remain ungated for any admitted session, **including Anonymous**.
|
||||
- ⚠️ **Node ACLs are enforced only behind `Security:Acl:Enabled` (default `false`), and only for three
|
||||
operations.** With the flag **off**, a key's grants collapse into the two server-wide role gates
|
||||
(`WriteOperate` on Value writes, `AlarmAck` on Part 9 methods) and **only `Cluster`-scoped grants
|
||||
count** — `ApiKeyInterimRoleMapper` deliberately under-grants, because honouring a narrower grant's
|
||||
flags while unable to honour its *scope* would turn "WriteOperate on Equipment X" into server-wide
|
||||
write authority. With the flag **on** (stage 5, `DataPlaneAuthorizer`), scoped grants decide **write,
|
||||
alarm commands and HistoryRead** — the last of which had no authorization check of any kind before.
|
||||
**Read, Browse and CreateMonitoredItems stay ungated in BOTH modes**, for any admitted session
|
||||
including Anonymous; that is stage 6.
|
||||
- ⚠️ **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
|
||||
nothing. The load-bearing test is therefore an **allow** — a session carrying *no roles* writing
|
||||
successfully on the strength of its grant. A deny-only test passes against a build where the ACL is
|
||||
never consulted, because the role gate denies too.
|
||||
- **The trie is built inside `DataPlaneAuthorityState.Apply`**, not by a second wiring at the publish
|
||||
actor, and the state owns its `PermissionTrieCache` rather than being handed one. An uninstalled
|
||||
trie denies everything, which is indistinguishable from working enforcement until someone checks
|
||||
that an ALLOW is possible at all.
|
||||
- **A deploy reaches sessions that are already open**, in both directions: each decision stamps the
|
||||
*current* generation rather than the one the session activated under, and re-checks the
|
||||
authenticating key — so revoking a key or a grant stops an open session immediately instead of at
|
||||
the client's next reconnect. On a driver-only node the artifact is the only revocation channel.
|
||||
- `UserAuthorizationState` is built **per call, not per session** — its freshness/staleness predicates
|
||||
are LDAP-shaped and cannot fire here. Caching belongs at stage 6's publish-path seams, keyed
|
||||
per-`(session, node)`.
|
||||
- **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
|
||||
|
||||
+19
-2
@@ -63,14 +63,31 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only
|
||||
|
||||
### `Security`
|
||||
|
||||
- **Purpose:** Admin-UI and OPC UA authentication. Three subsections, each its own Options class:
|
||||
- **Purpose:** Admin-UI and OPC UA authentication + data-plane authorization. Five subsections, each its own Options class:
|
||||
|
||||
| Subsection | Options class (`SectionName`) | Purpose |
|
||||
|---|---|---|
|
||||
| `Security:Ldap` | `LdapOptions` — `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs` | LDAP bind for Admin cookie login + OPC UA UserName tokens. Bound by `AddValidatedOptions<LdapOptions, LdapOptionsValidator>` in `Program.cs`. |
|
||||
| `Security:Ldap` | `LdapOptions` — `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs` | LDAP bind for **Admin cookie login only**. The OPC UA data plane moved to API keys in #520. Bound by `AddValidatedOptions<LdapOptions, LdapOptionsValidator>` in `Program.cs`. |
|
||||
| `Security:ApiKeys` | `ApiKeyOptions` — `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyOptions.cs` | OPC UA data-plane identity: the static `Username` every key-bearing client presents, and the server `Pepper` the stored key hashes are computed under. Bound unconditionally — a driver node needs the pepper to verify keys, an admin node to mint them. |
|
||||
| `Security:Acl` | `AclOptions` — `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclOptions.cs` | Whether node ACL grants decide data-plane authorization. See below. |
|
||||
| `Security:Jwt` | `JwtOptions` — `src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs` | Signing config for the JWT minted at `/auth/token` for **external** consumers (OPC UA clients / automation). |
|
||||
| `Security:Cookie` | `OtOpcUaCookieOptions` — `src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs` | The Admin-UI auth cookie (`AddOtOpcUaAuth` copies these onto `CookieAuthenticationOptions`). |
|
||||
|
||||
**`Security:ApiKeys`** key fields (`ApiKeyOptions`):
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `Username` | string | `apikey` | The fixed username a key-bearing client sends in its OPC UA UserName token. Carries no information — the key identifies the principal — but is checked rather than ignored so a typo is an explicit denial. |
|
||||
| `Pepper` | string | `""` | Server-side pepper for the stored key hashes. **Supply via `Security__ApiKeys__Pepper` or `${secret:}` — never commit it.** No default on purpose: an empty pepper still hashes "successfully", so `ApiKeySecret.ComputeHash` throws on one rather than producing a deployment with none of the protection it believes it has. Rotating it invalidates every existing key. |
|
||||
|
||||
**`Security:Acl`** key fields (`AclOptions`):
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `Enabled` | bool | `false` | `true` makes scoped `NodeAcl` grants decide **write**, **alarm-command** and **HistoryRead** authorization, *replacing* the server-wide `WriteOperate` / `AlarmAck` role checks. `false` keeps those role checks and leaves HistoryRead ungated — byte-for-byte the pre-stage-5 behaviour. Read, Browse and Subscribe are ungated either way. |
|
||||
|
||||
> **Flipping `Security:Acl:Enabled` is a config change, not a redeploy** — both paths are wired in both modes (the node builds its permission trie on every deploy regardless), the same dark-switch discipline as [`MeshTransport`](#meshtransport), [`ConfigSource`](#configsource) and [`Telemetry`](#telemetry). It defaults off because the first deployment carrying the code would otherwise start denying every client that has no key, **including ScadaBridge's Data Connection Layer, which consumes this address space from another repo.** Enforcement fails closed on every unresolvable case, so a wiring or config mistake presents as *everything denied* — check the `OpcUaPublish: data-plane authority generation N installed` line for a non-zero grant count before debugging an individual rule. See [`security.md` § Node ACL enforcement](security.md#node-acl-enforcement-securityaclenabled).
|
||||
|
||||
**`Security:Ldap` — see [`security.md`](security.md) for the full field-by-field reference and bind-flow.** The checked-in role overlays set only `DevStubMode` and `Transport`; the remaining `LdapOptions` fields (`Enabled`, `Server`, `Port`, `AllowInsecure`, `SearchBase`, `ServiceAccountDn`, `ServiceAccountPassword`, `GroupAttribute`, `DisplayNameAttribute`, `UserNameAttribute`, `GroupToRole`) are covered there.
|
||||
|
||||
**`Security:Jwt`** key fields (`JwtOptions`):
|
||||
|
||||
@@ -195,11 +195,46 @@ Each stage is one commit, builds clean, and leaves the tree shippable.
|
||||
Also settled here: the feed runs **before** `OpcUaPublishActor`'s `plan.IsEmpty` early-return. An
|
||||
artifact that only revokes a key produces an empty address-space plan, so feeding after that return
|
||||
would leave the revoked key working until some unrelated structural change happened to redeploy.
|
||||
5. **Enforce write + alarms + HistoryRead** behind `Security:Acl:Enabled`, using the seams that
|
||||
already exist. Includes building the per-session `UserAuthorizationState` and stamping it with the
|
||||
generation stage 4 now supplies, plus widening both `IApiKeyStore` implementations from
|
||||
Cluster-scoped grants to the full grant set (they narrow today because
|
||||
`ApiKeyInterimRoleMapper` cannot honour scope).
|
||||
5. **Enforce write + alarms + HistoryRead.** ✅ **Done.** `AclOptions` (`Security:Acl:Enabled`, default
|
||||
false); `DataPlaneAuthorizer` projects a NodeId + realm onto a `NodeScope` and evaluates it through the
|
||||
existing `TriePermissionEvaluator`; the trie is built and installed inside `DataPlaneAuthorityState.Apply`;
|
||||
both `IApiKeyStore` implementations widened to the full grant set.
|
||||
|
||||
Five things went differently from this line, all deliberate:
|
||||
|
||||
- **The role check is REPLACED, not supplemented.** Running both in series would require a grant 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 have stayed inert and the flag would have looked
|
||||
inert too. Narrow grants working is the point of the stage. Pinned by a test that writes
|
||||
successfully from a session carrying **no roles at all**; the deny-side test alone passes against
|
||||
a build where the ACL is never consulted, because the role gate denies too.
|
||||
- **`UserAuthorizationState` is built per CALL, not per session.** The per-session shape was
|
||||
LDAP-inherited: it exists so a long-lived session can re-resolve group membership that changed out
|
||||
of band. Nothing can change out of band under API keys, so the generation stamp is the whole
|
||||
invalidation signal and a per-call record picks it up for free — where a cached one would need
|
||||
explicit invalidation to do the same job. `IsStale`/`NeedsRefresh` therefore cannot fire here and
|
||||
are documented as vestigial rather than left looking load-bearing. Stage 6 is where caching earns
|
||||
its keep, and it wants a per-`(session, node)` decision cache, not this type.
|
||||
- **Revocation now reaches an OPEN session**, which the sketch did not ask for. Every decision
|
||||
re-checks the authenticating key against the current snapshot. Without it a key revoked by a
|
||||
deployment kept its open session's authority until the client chose to reconnect — indefinitely,
|
||||
for a machine-to-machine consumer — which would have made the documented
|
||||
"revocation requires a deploy" sharp edge considerably sharper than documented.
|
||||
- **The trie is built inside `DataPlaneAuthorityState.Apply`**, not by a separate install step at the
|
||||
publish actor. As two wirings, the trie install is the one that silently does not run, and an
|
||||
absent trie denies everything — indistinguishable from working enforcement until someone checks
|
||||
that an ALLOW is possible. Falsified: deleting the install turns 13 of the 22 stage-5 tests red.
|
||||
- **The deploy log now reports what the BUILD dropped** instead of re-deriving it. The previous
|
||||
`unresolvedScopes` count agreed with the builder on one of its four drop reasons; a rule with a
|
||||
blank `ScopeId`, a retired scope kind, or a scope path not starting at a realm vanished with the
|
||||
log reading zero.
|
||||
|
||||
Also found and fixed en route: **the principal never reached the session.** The authenticator passed
|
||||
it as `OpcUaUserAuthResult.DisplayName`, but `RoleCarryingUserIdentity` derives `DisplayName` from
|
||||
the presented *token* — which for every key-bearing client is the static username `apikey`. So the
|
||||
trie's matching key was being dropped at the identity boundary, and the audit `ClientUserId` on a
|
||||
rejected write recorded `apikey` for every consuming system. `Principal` and `ApiKeyId` are now
|
||||
carried explicitly.
|
||||
6. **Enforce read/browse/subscribe** — the new seam, with per-session decision caching.
|
||||
7. **AdminUI.** Key management (create/revoke, show-once) ✅ **done early, in stage 4's predecessor**; ACL authoring against principals, and
|
||||
**remove the two "not enforced" warning banners** — which is the honest signal that this is done.
|
||||
|
||||
+83
-26
@@ -228,10 +228,12 @@ Two consequences worth knowing:
|
||||
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,
|
||||
browses, subscriptions and history remain ungated for any admitted session. See
|
||||
⚠️ **Node ACLs are enforced only when `Security:Acl:Enabled` is set, and only for three operations.**
|
||||
With the flag **off — the default** — a key's grants are collapsed into the two server-wide role gates
|
||||
and **only `Cluster`-scoped grants count**, so a narrower grant confers nothing rather than silently
|
||||
widening to server-wide authority. With it **on**, writes, alarm commands and HistoryRead are decided
|
||||
by scoped `NodeAcl` grants; **reads, browses and subscriptions stay ungated in either mode** (stage 6).
|
||||
See [Node ACL enforcement](#node-acl-enforcement-securityaclenabled) and
|
||||
`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`.
|
||||
|
||||
### LDAP resilience — mostly retired with the data-plane authenticator (#520)
|
||||
@@ -263,14 +265,15 @@ the one request making it. One key remains:
|
||||
|
||||
## Data-Plane Authorization
|
||||
|
||||
> ⚠️ **Read this first (verified against source, 2026-07-27).** This section used to describe the
|
||||
> `NodeAcl` / `PermissionTrie` design as if it were the live enforcement path. **It is not.** The
|
||||
> evaluator is built and unit-tested but has **zero production call sites**, and
|
||||
> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not even reference the project it lives in. Everything
|
||||
> actually enforced today is in [What is enforced today](#what-is-enforced-today); the trie design is
|
||||
> retained below, clearly marked, under [Designed but not wired](#designed-but-not-wired-nodeacl--permissiontrie).
|
||||
> ⚠️ **Read this first (updated 2026-08-01, #520 stage 5).** Whether the `NodeAcl` / `PermissionTrie`
|
||||
> subsystem runs depends on one flag. With **`Security:Acl:Enabled` false — the default** — it does not:
|
||||
> the only authorization in the server is the coarse role checks in
|
||||
> [What is enforced with the flag off](#what-is-enforced-with-the-flag-off). With it **true**, those role
|
||||
> checks are *replaced* by scoped ACL decisions for write, alarm commands and HistoryRead — see
|
||||
> [Node ACL enforcement](#node-acl-enforcement-securityaclenabled). **Read, Browse and
|
||||
> CreateMonitoredItems are ungated in both modes.**
|
||||
|
||||
### What is enforced today
|
||||
### What is enforced with the flag off
|
||||
|
||||
There are exactly **three** authorization checks in the whole OPC UA server layer. All three are
|
||||
coarse, **server-wide role string checks** with no per-node component.
|
||||
@@ -311,11 +314,56 @@ The gate *is* realm-aware in one narrow sense: it is attached per-node during ma
|
||||
the Raw and UNS realms, and the write dispatch it guards is realm-qualified. The role decision itself
|
||||
takes no realm and no node.
|
||||
|
||||
### Designed but not wired: `NodeAcl` + `PermissionTrie`
|
||||
### Node ACL enforcement (`Security:Acl:Enabled`)
|
||||
|
||||
Everything from here to [Role grant source](#role-grant-source-data-plane) describes a subsystem that
|
||||
**exists in the tree, is covered by 30 unit tests, and never executes.** It is kept because the design
|
||||
is sound and the remaining work is wiring plus one genuine design gap — not because it runs.
|
||||
| Key | Default | Effect |
|
||||
|---|---|---|
|
||||
| `Security:Acl:Enabled` | `false` | `true` makes scoped `NodeAcl` grants decide write, alarm-command and HistoryRead authorization |
|
||||
|
||||
**It is a config change, not a redeploy.** Both paths are wired in both modes — the node always builds
|
||||
its permission trie on every deploy and always constructs the gate; the flag only chooses which decision
|
||||
the seams consult. Same dark-switch discipline as `MeshTransport`, `ConfigSource` and `Telemetry`.
|
||||
|
||||
#### What changes when it is on
|
||||
|
||||
| Operation | Off | On |
|
||||
|---|---|---|
|
||||
| Write (Value) | role `WriteOperate` | `NodePermissions.WriteOperate` on that node's scope |
|
||||
| Alarm Acknowledge / AddComment / Enable / Disable | role `AlarmAck` | `AlarmAcknowledge` on the condition's scope |
|
||||
| Alarm Confirm | role `AlarmAck` | `AlarmConfirm` |
|
||||
| Alarm OneShotShelve / TimedShelve / Unshelve | role `AlarmAck` | `AlarmShelve` |
|
||||
| HistoryRead (all four variants) | **nothing** | `HistoryRead` on each node's scope, decided **per node in the batch** |
|
||||
| Read, Browse, CreateMonitoredItems | nothing | **still nothing** — stage 6 |
|
||||
|
||||
⚠️ **The ACL decision REPLACES the role check; the two are not chained.** Chaining them would require a
|
||||
grant to confer the server-wide role *as well as* pass the scope check, and only `Cluster`-scoped grants
|
||||
confer a role (`ApiKeyInterimRoleMapper`) — so every narrow grant would remain inert and the flag would
|
||||
look like it did nothing. Narrow grants working is the whole point of the stage.
|
||||
|
||||
Two behaviours worth knowing before enabling it:
|
||||
|
||||
- **A deploy takes effect on sessions that are already open.** The gate stamps each decision with the
|
||||
current authority generation rather than the one the session activated under, so an ACL edit lands
|
||||
without bouncing clients — in both directions, widening and narrowing.
|
||||
- **Revocation also reaches an open session.** Every decision re-checks the authenticating key against
|
||||
the current snapshot, so a key disabled or expired by a later deployment stops working immediately
|
||||
rather than at the client's next reconnect. On a driver-only node the artifact is the only revocation
|
||||
channel there is, so without this a revoked credential could keep its authority indefinitely.
|
||||
|
||||
#### Everything denies when something is missing
|
||||
|
||||
No identity, no principal on the identity, **no cluster identity on the node**, a NodeId with no string
|
||||
identifier, or a trie that was never installed — each returns `BadUserAccessDenied`. The client is told
|
||||
nothing beyond that; the reason is logged server-side (`AclGate: denied …`) so the server cannot be used
|
||||
as an oracle for which nodes exist or which grants a principal holds.
|
||||
|
||||
The consequence to plan for: a wiring or configuration mistake presents as **everything denied**, not
|
||||
everything allowed. Confirm the trie exists at all before debugging a specific rule — the
|
||||
`OpcUaPublish: data-plane authority generation N installed` line reports the grant count, and a separate
|
||||
warning names every rule the build **dropped** (a dangling `ScopeId`, a blank one, a retired scope kind,
|
||||
or a scope path that does not start at a realm). A dropped rule can never match anything.
|
||||
|
||||
### The `NodeAcl` + `PermissionTrie` subsystem
|
||||
|
||||
What is real about it:
|
||||
|
||||
@@ -323,13 +371,16 @@ What is real about it:
|
||||
validated, versioned and persisted.
|
||||
- `ConfigComposer` **does** snapshot every `NodeAcl` row into every deployment artifact, so an ACL
|
||||
edit shifts the artifact's `RevisionHash` and ships to every node.
|
||||
- The node side **does** deserialize them as of #520 stage 4 —
|
||||
- The node side **does** deserialize them (#520 stage 4) —
|
||||
`DeploymentArtifact.ParseDataPlaneAuthority` reads the artifact's `NodeAcls` (scoped to the node's own
|
||||
cluster) and `ApiKeys`, and `OpcUaPublishActor` installs both on the node's
|
||||
`IDataPlaneAuthoritySource` on every apply. The node manager holds that source, so it finally has a
|
||||
cluster identity.
|
||||
- **Nothing evaluates them yet.** The grants are decoded and available; the enforcement seams that would
|
||||
consult them land in stages 5 and 6.
|
||||
`IDataPlaneAuthoritySource` on every apply. The node manager holds that source, so it has a cluster
|
||||
identity.
|
||||
- The same `Apply` call **builds and installs the permission trie** (#520 stage 5), under the same
|
||||
generation as the snapshot. Deliberately one call rather than two wirings: an uninstalled trie denies
|
||||
everything, which is indistinguishable from working enforcement until someone checks that an ALLOW is
|
||||
possible at all.
|
||||
- `DataPlaneAuthorizer` **evaluates** them at the three seams above, when the flag is on.
|
||||
|
||||
So an operator can still author a grant, deploy it green, and have it change nothing at the node — but
|
||||
the grant now reaches the node rather than being dropped at its door. That is the behaviour to expect
|
||||
@@ -352,12 +403,18 @@ until the tracking issue below is closed.
|
||||
can never reach. A scope resolves to *several* paths when one entity is reachable at several NodeIds
|
||||
(a raw tag projected into N equipments has one raw path plus N UNS paths), because a grant that
|
||||
covered only some of them would be bypassable through the one it missed.
|
||||
4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but
|
||||
nothing in production constructs one per session. **Half-closed by stage 4:** the *generation* now
|
||||
exists and advances — `DataPlaneAuthorityState` stamps a monotonic `GenerationId` each time an
|
||||
artifact with a new `RevisionHash` is applied — so there is something to stamp a session with. Doing
|
||||
the stamping is stage 5's job, because until an enforcement seam reads it, a per-session state object
|
||||
would be constructed and never consulted.
|
||||
4. ~~**No session lifecycle exists.**~~ **Closed by #520 stage 5, though not as the blocker imagined.**
|
||||
The generation exists and advances (stage 4), and `DataPlaneAuthorizer` now builds a
|
||||
`UserAuthorizationState` stamped with it — but **per authorization call, not per session**. The
|
||||
per-session design was LDAP-shaped: it assumed a directory could change a session's group membership
|
||||
out of band, so a long-lived session had to re-resolve on a timer and serve cached memberships in
|
||||
between. Under API keys nothing can change out of band — the principal comes from the key, and keys
|
||||
and grants both ride the deployment artifact — so the generation stamp is the entire invalidation
|
||||
signal, and rebuilding a small record per call picks it up for free while a cached one would have to
|
||||
be explicitly invalidated. The consequence is that `UserAuthorizationState.IsStale` and `NeedsRefresh`
|
||||
can never fire on this path; they are vestigial here rather than load-bearing. Caching becomes
|
||||
necessary at stage 6's read/browse/subscribe seams, which are on the publish path — and it will want
|
||||
a per-`(session, node)` decision cache, not this type.
|
||||
|
||||
`NodeAclScopeKind.FolderSegment` is the retired v2 kind with no v3 entity behind it. It is still offered
|
||||
in the authoring dropdown (labelled retired), but it can never resolve, and both ends now say so
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// <c>Security:Acl</c> — the switch that turns per-node ACL evaluation into the authorization
|
||||
/// decision on the OPC UA data plane (#520, stage 5).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Default OFF, and that is the whole point.</b> This is the dark-switch discipline
|
||||
/// <c>MeshTransport</c>, <c>ConfigSource</c> and <c>Telemetry</c> all shipped under: both sides
|
||||
/// are wired in both modes, so flipping it is a config change rather than a redeploy. Without it,
|
||||
/// the first deployment carrying this code would start denying every client that has no key —
|
||||
/// including <b>ScadaBridge's Data Connection Layer, which consumes this address space from
|
||||
/// another repo</b> and has never presented one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What <see cref="Enabled"/> actually swaps.</b> With it off, the two long-standing server-wide
|
||||
/// role gates (<c>WriteOperate</c> on Value writes, <c>AlarmAck</c> on the Part 9 methods) remain
|
||||
/// the only authorization in the server and behave exactly as they always have; HistoryRead stays
|
||||
/// ungated. With it on, those role checks are <b>replaced</b> — not supplemented — by a scoped ACL
|
||||
/// decision, and HistoryRead gains one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Replaced, not supplemented, is load-bearing.</b> Running both in series would mean a grant
|
||||
/// had to confer the server-wide role as well as pass the scope check, and only
|
||||
/// <c>Cluster</c>-scoped grants confer a role (see <c>ApiKeyInterimRoleMapper</c>) — so every
|
||||
/// narrow grant would still be inert and the flag would appear to do nothing. Narrow grants
|
||||
/// working is the entire reason this stage exists.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Read, Browse and CreateMonitoredItems are still ungated in either mode — that is stage 6, and
|
||||
/// the AdminUI banners stay up until it lands.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AclOptions
|
||||
{
|
||||
/// <summary>The configuration section name.</summary>
|
||||
public const string SectionName = "Security:Acl";
|
||||
|
||||
/// <summary>
|
||||
/// When true, node ACL grants decide write, alarm-command and HistoryRead authorization. Default
|
||||
/// false, which preserves the pre-stage-5 role gates byte-for-byte.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
@@ -32,6 +32,7 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
IReadOnlyList<ApiKey> keys,
|
||||
IReadOnlyList<NodeAcl> grants,
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||
IReadOnlyList<PermissionTrieBuildDiagnostic> droppedGrants,
|
||||
IReadOnlyDictionary<string, ApiKey> keysById,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal)
|
||||
{
|
||||
@@ -41,6 +42,7 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
Keys = keys;
|
||||
Grants = grants;
|
||||
ScopePaths = scopePaths;
|
||||
DroppedGrants = droppedGrants;
|
||||
_keysById = keysById;
|
||||
_grantsByPrincipal = grantsByPrincipal;
|
||||
}
|
||||
@@ -76,6 +78,18 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Grants the trie build discarded, with the reason. Each one is an authored rule that will never
|
||||
/// match anything, so this is the only signal an operator gets that a rule they wrote is inert.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reported by the build itself rather than re-derived by the caller. An earlier version of the
|
||||
/// install log counted unresolvable scopes independently, which agreed with the builder only for
|
||||
/// the <c>UnresolvedScope</c> reason — it silently missed rows dropped for a missing
|
||||
/// <c>ScopeId</c>, a retired scope kind, or a scope path that does not start at a realm.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<PermissionTrieBuildDiagnostic> DroppedGrants { get; }
|
||||
|
||||
/// <summary>True once a real artifact has been applied.</summary>
|
||||
public bool HasArtifact => GenerationId > 0;
|
||||
|
||||
@@ -112,6 +126,7 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
/// <param name="keys">The API keys carried by the artifact.</param>
|
||||
/// <param name="grants">The ACL grants for this node's cluster.</param>
|
||||
/// <param name="scopePaths">Where each grant's scope sits in the address space.</param>
|
||||
/// <param name="droppedGrants">Grants the trie build discarded, or empty when no trie was built.</param>
|
||||
/// <returns>The built snapshot.</returns>
|
||||
public static DataPlaneAuthoritySnapshot Create(
|
||||
string? clusterId,
|
||||
@@ -119,7 +134,8 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
long generationId,
|
||||
IReadOnlyList<ApiKey> keys,
|
||||
IReadOnlyList<NodeAcl> grants,
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths)
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||
IReadOnlyList<PermissionTrieBuildDiagnostic>? droppedGrants = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(revisionHash);
|
||||
ArgumentNullException.ThrowIfNull(keys);
|
||||
@@ -154,7 +170,7 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new DataPlaneAuthoritySnapshot(
|
||||
clusterId, revisionHash, generationId, keys, grants, scopePaths, byId, frozen);
|
||||
clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,11 +223,35 @@ public interface IDataPlaneAuthoritySink
|
||||
/// Process-singleton holder of <see cref="DataPlaneAuthoritySnapshot"/>. Registered on every
|
||||
/// driver-role node; the artifact-apply path writes it and OPC UA session activation reads it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>It owns the <see cref="PermissionTrieCache"/> rather than being handed one</b> (#520 stage 5).
|
||||
/// The trie is built from the same grants and the same generation as the snapshot, inside the same
|
||||
/// <see cref="Apply"/> call, so the two cannot describe different deployments. Injecting the cache
|
||||
/// would have made "install the snapshot" and "install the trie" two wirings, and this repo has
|
||||
/// been bitten repeatedly by the second one silently not running — a trie that was never installed
|
||||
/// denies everything, which reads as "ACLs are working" until someone checks whether an ALLOW is
|
||||
/// possible at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPlaneAuthoritySink
|
||||
{
|
||||
/// <summary>
|
||||
/// Generations of the trie retained per cluster. One is enough for correctness now — every
|
||||
/// authorization call stamps itself with the current generation — but an in-flight request that
|
||||
/// read the snapshot just before a deploy landed still resolves against the generation it saw.
|
||||
/// </summary>
|
||||
private const int RetainedTrieGenerations = 3;
|
||||
|
||||
private readonly Lock _gate = new();
|
||||
private DataPlaneAuthoritySnapshot _current = DataPlaneAuthoritySnapshot.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The permission tries built from the applied artifacts, keyed <c>(cluster, generation)</c>. Read
|
||||
/// by <see cref="TriePermissionEvaluator"/> on the authorization path.
|
||||
/// </summary>
|
||||
public PermissionTrieCache Tries { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public DataPlaneAuthoritySnapshot Current => Volatile.Read(ref _current);
|
||||
|
||||
@@ -244,10 +284,49 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
|
||||
? previous.GenerationId
|
||||
: previous.GenerationId + 1;
|
||||
|
||||
// Build and install the trie BEFORE publishing the snapshot. An authorization call that reads
|
||||
// the new snapshot must find its trie already in the cache; publishing first opens a window in
|
||||
// which a session stamps itself with a generation the cache has not got, and the evaluator
|
||||
// fails closed on exactly that condition. The window would be microseconds and only ever
|
||||
// reachable during a deploy — precisely the kind of defect that never reproduces.
|
||||
var dropped = BuildAndInstallTrie(clusterId, generation, grants, scopePaths);
|
||||
|
||||
var next = DataPlaneAuthoritySnapshot.Create(
|
||||
clusterId, revisionHash, generation, keys, grants, scopePaths);
|
||||
clusterId, revisionHash, generation, keys, grants, scopePaths, dropped);
|
||||
Volatile.Write(ref _current, next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the permission trie for a generation and installs it in <see cref="Tries"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A node with no cluster identity gets no trie. The trie is keyed by cluster and the evaluator
|
||||
/// matches the session's cluster against the node's, so there is no honest key to file it under;
|
||||
/// inventing a sentinel would silently apply one cluster's grants to a node whose cluster is
|
||||
/// unknown. With enforcement enabled such a node denies everything, which is the fail-closed
|
||||
/// direction and is surfaced by the caller's install log.
|
||||
/// </remarks>
|
||||
/// <param name="clusterId">The node's cluster, or null/blank when it has none.</param>
|
||||
/// <param name="generationId">The generation to file the trie under.</param>
|
||||
/// <param name="grants">The ACL grants to plant.</param>
|
||||
/// <param name="scopePaths">Where each grant's scope sits in the address space.</param>
|
||||
/// <returns>The grants the build discarded.</returns>
|
||||
private IReadOnlyList<PermissionTrieBuildDiagnostic> BuildAndInstallTrie(
|
||||
string? clusterId,
|
||||
long generationId,
|
||||
IReadOnlyList<NodeAcl> grants,
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clusterId)) return [];
|
||||
|
||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||
var trie = PermissionTrieBuilder.Build(
|
||||
clusterId, generationId, grants, scopePaths, diagnostics.Add);
|
||||
|
||||
Tries.Install(trie);
|
||||
Tries.Prune(clusterId, RetainedTrieGenerations);
|
||||
return diagnostics;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,21 +29,28 @@ else if (!IsNew && _existing is null)
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Only <span class="mono">Cluster</span> scope is enforced today.</strong>
|
||||
Saved with scope kind <span class="mono">Cluster</span>, this grant confers a server-wide role
|
||||
on its principal: <span class="mono">WriteOperate</span> → writing any tag value;
|
||||
<strong>What this grant does depends on <span class="mono">Security:Acl:Enabled</span> on the
|
||||
node serving the data.</strong> That is per-node configuration, so this page cannot tell you
|
||||
which mode a given node is in — check the node's own settings.
|
||||
<br />
|
||||
<strong>With it off (the default):</strong> saved with scope kind
|
||||
<span class="mono">Cluster</span> this grant confers a <em>server-wide</em> role on its
|
||||
principal — <span class="mono">WriteOperate</span> → writing any tag value;
|
||||
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
|
||||
(acknowledge / confirm / shelve). No other permission bit is evaluated yet.
|
||||
(acknowledge / confirm / shelve). Saved with any narrower scope
|
||||
(<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
|
||||
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
|
||||
<span class="mono">Tag</span>, <span class="mono">Raw*</span>) it is stored and shipped in every
|
||||
deployment artifact but <strong>confers nothing</strong>. That under-granting is deliberate:
|
||||
honouring its flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on one
|
||||
equipment" into server-wide write authority.
|
||||
<br />
|
||||
Saved with any narrower scope (<span class="mono">Namespace</span>,
|
||||
<span class="mono">UnsArea</span>, <span class="mono">UnsLine</span>,
|
||||
<span class="mono">Equipment</span>, <span class="mono">Tag</span>,
|
||||
<span class="mono">Raw*</span>) it is stored and shipped in every deployment artifact but
|
||||
<strong>confers nothing</strong> until per-node enforcement lands. That under-granting is
|
||||
deliberate: honouring its flags while its <em>scope</em> cannot be honoured would turn
|
||||
"WriteOperate on one equipment" into server-wide write authority.
|
||||
<strong>With it on:</strong> the scope is honoured, and the scoped decision <em>replaces</em> the
|
||||
server-wide role rather than adding to it. Writes, alarm commands and HistoryRead are gated; the
|
||||
alarm methods separate into <span class="mono">AlarmAcknowledge</span> /
|
||||
<span class="mono">AlarmConfirm</span> / <span class="mono">AlarmShelve</span>.
|
||||
<br />
|
||||
Read, Browse, Subscribe and HistoryRead are <strong>not</strong> gated for any admitted
|
||||
<strong>Read, Browse and Subscribe are not gated in either mode</strong>, for any admitted
|
||||
session, including anonymous.
|
||||
<br />
|
||||
<strong>Principal</strong> is an API-key principal — or the reserved
|
||||
|
||||
@@ -20,21 +20,31 @@
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Only <span class="mono">Cluster</span>-scoped grants are enforced today.</strong>
|
||||
A Cluster-scoped row confers a server-wide role on its principal:
|
||||
<strong>What these grants do depends on
|
||||
<span class="mono">Security:Acl:Enabled</span> on the node serving the data.</strong>
|
||||
That is per-node configuration, so this page cannot tell you which mode a given node is in —
|
||||
check the node's own settings.
|
||||
<br />
|
||||
<strong>With it off (the default):</strong> only <span class="mono">Cluster</span>-scoped grants
|
||||
do anything, and they confer a <em>server-wide</em> role on their principal —
|
||||
<span class="mono">WriteOperate</span> → writing any tag value;
|
||||
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
|
||||
(acknowledge / confirm / shelve). No other permission bit is evaluated yet.
|
||||
<br />
|
||||
<strong>Narrower grants confer nothing.</strong>
|
||||
<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
|
||||
(acknowledge / confirm / shelve). Narrower grants
|
||||
(<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
|
||||
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
|
||||
<span class="mono">Tag</span> and the <span class="mono">Raw*</span> kinds are saved and shipped
|
||||
in every deployment artifact, but stay inert until per-node enforcement lands. That
|
||||
under-granting is deliberate: honouring such a grant's flags while its <em>scope</em> cannot be
|
||||
honoured would turn "WriteOperate on one equipment" into server-wide write authority.
|
||||
<span class="mono">Tag</span>, <span class="mono">Raw*</span>) are saved and shipped in every
|
||||
deployment artifact but confer <strong>nothing</strong>. That under-granting is deliberate:
|
||||
honouring such a grant's flags while its <em>scope</em> cannot be honoured would turn
|
||||
"WriteOperate on one equipment" into server-wide write authority.
|
||||
<br />
|
||||
Read, Browse, Subscribe and HistoryRead are <strong>not</strong> gated for any admitted
|
||||
<strong>With it on:</strong> every scope kind is honoured, and the scoped decision
|
||||
<em>replaces</em> the server-wide role rather than adding to it — so a narrow grant works, and a
|
||||
Cluster-scoped one no longer confers authority beyond that cluster. Writes, alarm commands and
|
||||
HistoryRead are gated; the alarm methods separate into
|
||||
<span class="mono">AlarmAcknowledge</span> / <span class="mono">AlarmConfirm</span> /
|
||||
<span class="mono">AlarmShelve</span>.
|
||||
<br />
|
||||
<strong>Read, Browse and Subscribe are not gated in either mode</strong>, for any admitted
|
||||
session, including anonymous.
|
||||
<br />
|
||||
Identity is an <strong>API key</strong>, not LDAP: a client presents an OPC UA UserName token
|
||||
|
||||
@@ -140,7 +140,10 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||
"ApiKeyAuth: granted key {Name} ({ApiKeyId}) as principal {Principal} with roles [{Roles}]",
|
||||
lookup.Key.Name, apiKeyId, lookup.Key.Principal, string.Join(",", roles));
|
||||
|
||||
return OpcUaUserAuthResult.Allow(lookup.Key.Principal, roles);
|
||||
// AllowKey, not Allow: the key id rides along so an open session can be re-checked against a later
|
||||
// deployment that revoked this key. Without it a revoked key keeps working until the client
|
||||
// happens to disconnect, which on a machine-to-machine data plane can be indefinitely.
|
||||
return OpcUaUserAuthResult.AllowKey(lookup.Key.Principal, roles, apiKeyId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -31,6 +31,22 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
/// safe direction, and it is also honest — a node that has not been deployed to has no configured
|
||||
/// keys.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Returns a principal's FULL grant set</b> (#520 stage 5). It used to narrow to the
|
||||
/// <c>Cluster</c>-scoped rows, because those were the only ones that could confer an interim role.
|
||||
/// The narrowing was always redundant — <see cref="ApiKeyInterimRoleMapper"/> applies exactly the
|
||||
/// same filter itself — and it became a trap once scope was enforced: a caller reasonably reading
|
||||
/// <c>ApiKeyLookup.Grants</c> as "this principal's grants" would have got a silently truncated set.
|
||||
/// Removing it changes no role.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cluster confinement is not this store's job.</b>
|
||||
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> already kept only the node's own cluster's ACL
|
||||
/// rows (<c>ClusterFilterMode.ScopeTo</c>, matched <see cref="StringComparison.OrdinalIgnoreCase"/>),
|
||||
/// so the snapshot cannot contain another cluster's grant. <see cref="ConfigDbApiKeyStore"/> reads
|
||||
/// the fleet-wide ConfigDb and therefore has to apply that confinement itself; the two stores agree
|
||||
/// on the outcome, not on the mechanism.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ArtifactApiKeyStore : IApiKeyStore
|
||||
{
|
||||
@@ -53,38 +69,10 @@ public sealed class ArtifactApiKeyStore : IApiKeyStore
|
||||
var key = snapshot.FindUsableKey(apiKeyId, utcNow);
|
||||
return Task.FromResult(key is null
|
||||
? null
|
||||
: new ApiKeyLookup(key, ClusterGrants(snapshot, key.Principal)));
|
||||
: new ApiKeyLookup(key, snapshot.GrantsFor(key.Principal)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
|
||||
Task.FromResult(ClusterGrants(_authority.Current, principal));
|
||||
|
||||
/// <summary>
|
||||
/// Narrows a principal's grants to the Cluster-scoped ones. Only those confer an interim role
|
||||
/// (see <see cref="ApiKeyInterimRoleMapper"/>); stage 5 widens both stores to the full grant set
|
||||
/// once scope is actually enforced.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Scope kind is the ONLY narrowing needed here — cluster confinement already happened.</b>
|
||||
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> kept only the node's own cluster's ACL rows
|
||||
/// (<c>ClusterFilterMode.ScopeTo</c>, matched <see cref="StringComparison.OrdinalIgnoreCase"/>),
|
||||
/// so this snapshot cannot contain another cluster's grant. <c>ConfigDbApiKeyStore</c> reads the
|
||||
/// fleet-wide ConfigDb and therefore has to apply that confinement itself; the two stores agree on
|
||||
/// the outcome, not on the mechanism. An earlier version of this comment claimed
|
||||
/// <c>ConfigDbApiKeyStore</c> "filters the same way in SQL" — it did not filter on cluster at all,
|
||||
/// which was the cross-cluster over-grant this remark now exists to keep straight.
|
||||
/// </remarks>
|
||||
private static IReadOnlyList<NodeAcl> ClusterGrants(DataPlaneAuthoritySnapshot snapshot, string principal)
|
||||
{
|
||||
var all = snapshot.GrantsFor(principal);
|
||||
if (all.Count == 0) return [];
|
||||
|
||||
var scoped = new List<NodeAcl>(all.Count);
|
||||
foreach (var grant in all)
|
||||
{
|
||||
if (grant.ScopeKind == NodeAclScopeKind.Cluster) scoped.Add(grant);
|
||||
}
|
||||
return scoped;
|
||||
}
|
||||
Task.FromResult(_authority.Current.GrantsFor(principal));
|
||||
}
|
||||
|
||||
@@ -104,24 +104,34 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
|
||||
|
||||
if (key is null) return null;
|
||||
|
||||
return new ApiKeyLookup(key, await LoadClusterGrantsAsync(db, key.Principal, ct).ConfigureAwait(false));
|
||||
return new ApiKeyLookup(key, await LoadGrantsAsync(db, key.Principal, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
|
||||
{
|
||||
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
return await LoadClusterGrantsAsync(db, principal, ct).ConfigureAwait(false);
|
||||
return await LoadGrantsAsync(db, principal, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<NodeAcl>> LoadClusterGrantsAsync(
|
||||
/// <summary>Loads a principal's grants, confined to this node's cluster.</summary>
|
||||
/// <remarks>
|
||||
/// <b>The full grant set, not just the <c>Cluster</c>-scoped rows</b> (#520 stage 5). The old
|
||||
/// narrowing existed because only cluster-scoped rows could confer an interim role, but
|
||||
/// <see cref="ApiKeyInterimRoleMapper"/> applies that same filter itself, so it was redundant even
|
||||
/// then — and once scope is enforced it becomes a trap, silently truncating what
|
||||
/// <c>ApiKeyLookup.Grants</c> claims to be. Removing it changes no role.
|
||||
/// </remarks>
|
||||
/// <param name="db">The config database.</param>
|
||||
/// <param name="principal">The principal to load grants for.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The principal's grants in this node's cluster.</returns>
|
||||
private async Task<IReadOnlyList<NodeAcl>> LoadGrantsAsync(
|
||||
OtOpcUaConfigDbContext db, string principal, CancellationToken ct)
|
||||
{
|
||||
// Only cluster-scoped rows can confer an interim role (see ApiKeyInterimRoleMapper), so only
|
||||
// those are fetched. Stage 5 widens this to the full grant set for per-node evaluation.
|
||||
var rows = await db.NodeAcls
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Principal == principal && a.ScopeKind == NodeAclScopeKind.Cluster)
|
||||
.Where(a => a.Principal == principal)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
||||
private readonly IHistorianDataSource _historianDataSource;
|
||||
private readonly IDataPlaneAuthoritySource _dataPlaneAuthority;
|
||||
private readonly DataPlaneAuthorizer? _dataPlaneAuthorizer;
|
||||
private readonly ServerHistorianOptions _serverHistorianOptions;
|
||||
private readonly Func<ActorSystem> _actorSystemAccessor;
|
||||
private readonly ActorRegistry _actorRegistry;
|
||||
@@ -73,6 +74,9 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <param name="actorRegistry">The Akka.Hosting actor registry, used to resolve the local
|
||||
/// <c>DriverHostActor</c> ref (<c>DriverHostActorKey</c>) the inbound node-write router Asks. Resolved
|
||||
/// in <see cref="StartAsync"/> after the runtime actors have been registered.</param>
|
||||
/// <param name="dataPlaneAuthorizer">#520 stage 5 — the node-scoped ACL gate. Nullable, and null means
|
||||
/// "not wired" rather than "deny": an unregistered gate must not lock a node out of writes to protect a
|
||||
/// feature that is off by default. Once wired AND enabled, every unresolvable case inside it denies.</param>
|
||||
/// <param name="loggerFactory">The logger factory for creating loggers.</param>
|
||||
public OtOpcUaServerHostedService(
|
||||
IOptions<OpcUaApplicationHostOptions> options,
|
||||
@@ -84,8 +88,10 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
IConfiguration configuration,
|
||||
Func<ActorSystem> actorSystemAccessor,
|
||||
ActorRegistry actorRegistry,
|
||||
DataPlaneAuthorizer? dataPlaneAuthorizer,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_dataPlaneAuthorizer = dataPlaneAuthorizer;
|
||||
_options = options.Value;
|
||||
_deferredSink = deferredSink;
|
||||
_deferredServiceLevel = deferredServiceLevel;
|
||||
@@ -225,12 +231,22 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
// The node manager's HistoryRead overrides block-bridge to whatever source is set here.
|
||||
_server.SetHistorianDataSource(_historianDataSource);
|
||||
|
||||
// #520 stage 4 — hand the node manager the node's data-plane authorization inputs. Nothing reads
|
||||
// them until the stage 5/6 enforcement seams land, so this changes no behaviour today; it exists
|
||||
// because the node manager had NO cluster identity, and NodeScope.ClusterId is required while the
|
||||
// evaluator fails closed on a mismatch — every scope it could have built would have been denied.
|
||||
// #520 stage 4 — hand the node manager the node's data-plane authorization inputs. It exists because
|
||||
// the node manager had NO cluster identity, and NodeScope.ClusterId is required while the evaluator
|
||||
// fails closed on a mismatch — every scope it could have built would have been denied.
|
||||
_server.SetDataPlaneAuthority(_dataPlaneAuthority);
|
||||
|
||||
// #520 stage 5 — hand it the node-scoped ACL gate. Still behaviour-neutral unless
|
||||
// Security:Acl:Enabled is set: the gate reports itself disabled and every seam keeps its
|
||||
// server-wide role check.
|
||||
_server.SetDataPlaneAuthorizer(_dataPlaneAuthorizer);
|
||||
if (_dataPlaneAuthorizer is { Enabled: true })
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"OtOpcUaServerHostedService: node ACL enforcement is ENABLED — writes, alarm commands and "
|
||||
+ "HistoryRead now require a matching NodeAcl grant. Read, Browse and Subscribe remain ungated.");
|
||||
}
|
||||
|
||||
// Propagate the configured tie-cluster over-fetch bound onto the node manager so HistoryRead-Raw
|
||||
// can page WITHIN an oversized tie cluster (more samples sharing one SourceTimestamp than the page
|
||||
// cap) rather than failing the read. The option's default (65536) survives when the ServerHistorian
|
||||
@@ -274,6 +290,10 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
_server?.SetHistorianDataSource(null);
|
||||
// Restore the empty authority so a late session activation cannot authorize against a stopping node.
|
||||
_server?.SetDataPlaneAuthority(null);
|
||||
// Detach the ACL gate too. Leaving it attached alongside an emptied authority would deny every late
|
||||
// request rather than let the seams fall back to their role checks — a stopping node should shed
|
||||
// enforcement in the same order it acquired it.
|
||||
_server?.SetDataPlaneAuthorizer(null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,9 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua");
|
||||
// to MINT them, and getting this wrong on either side is a silent authentication failure rather than a
|
||||
// startup error.
|
||||
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection(ApiKeyOptions.SectionName));
|
||||
// #520 stage 5 — Security:Acl:Enabled. Default false; see AclOptions for why the flip must be a config
|
||||
// change rather than a redeploy, and what it swaps rather than adds.
|
||||
builder.Services.Configure<AclOptions>(builder.Configuration.GetSection(AclOptions.SectionName));
|
||||
|
||||
if (hasAdmin)
|
||||
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
|
||||
@@ -360,6 +363,14 @@ if (hasDriver)
|
||||
builder.Services.AddSingleton<DataPlaneAuthorityState>();
|
||||
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
|
||||
builder.Services.AddSingleton<IDataPlaneAuthoritySink>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
|
||||
|
||||
// #520 stage 5 — the tries the ACL gate evaluates against are taken FROM the authority state, not
|
||||
// registered independently. The state builds the trie inside the same Apply() call that installs
|
||||
// the snapshot, so resolving any other cache here would hand the evaluator a permanently empty
|
||||
// one — and an empty trie denies everything, which looks exactly like "the ACLs are working" until
|
||||
// someone checks that an ALLOW is possible at all. Deriving it from the owner makes that mistake
|
||||
// unrepresentable.
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<DataPlaneAuthorityState>().Tries);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -369,8 +380,24 @@ if (hasDriver)
|
||||
// fix, as the NullOpcUaUserAuthenticator registration below. Only the SINK is genuinely optional
|
||||
// (nothing publishes artifacts on a node with no driver role).
|
||||
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(EmptyDataPlaneAuthoritySource.Instance);
|
||||
// Nothing installs a trie on a node with no driver role; an empty cache denies every scoped
|
||||
// decision, which is correct for a node that serves no data plane.
|
||||
builder.Services.AddSingleton<PermissionTrieCache>();
|
||||
}
|
||||
|
||||
// #520 stage 5 — the node-scoped ACL gate. Registered on EVERY node, not just driver ones, because
|
||||
// OtOpcUaServerHostedService takes it as a constructor parameter and the built-in container resolves
|
||||
// constructors by whether every parameter is registered, not by whether the parameter type is nullable.
|
||||
// That is the same trap the IOpcUaUserAuthenticator and IDataPlaneAuthoritySource registrations above
|
||||
// both carry a comment about; an absent registration is a node that will not start.
|
||||
builder.Services.AddSingleton<IPermissionEvaluator>(sp =>
|
||||
new TriePermissionEvaluator(sp.GetRequiredService<PermissionTrieCache>()));
|
||||
builder.Services.AddSingleton(sp => new DataPlaneAuthorizer(
|
||||
sp.GetRequiredService<IDataPlaneAuthoritySource>(),
|
||||
sp.GetRequiredService<IPermissionEvaluator>(),
|
||||
sp.GetRequiredService<IOptions<AclOptions>>().Value.Enabled,
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<DataPlaneAuthorizer>()));
|
||||
|
||||
if (hasAdmin)
|
||||
{
|
||||
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
|
||||
|
||||
@@ -335,9 +335,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
args.Identity = new RoleCarryingUserIdentity(token, result.Roles);
|
||||
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} ({Roles})",
|
||||
token.UserName, string.Join(",", result.Roles));
|
||||
args.Identity = new RoleCarryingUserIdentity(token, result.Roles, result.Principal, result.ApiKeyId);
|
||||
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} as {Principal} ({Roles})",
|
||||
token.UserName, result.Principal, string.Join(",", result.Roles));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -394,7 +394,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
args.Identity = new RoleCarryingUserIdentity(token, result.Roles);
|
||||
args.Identity = new RoleCarryingUserIdentity(token, result.Roles, result.Principal, apiKeyId: null);
|
||||
logger.LogInformation("OpcUaApplicationHost: anonymous session admitted as {Principal} ({Roles})",
|
||||
OpcUaPrincipals.Anonymous,
|
||||
result.Roles.Count == 0 ? "no roles" : string.Join(",", result.Roles));
|
||||
|
||||
@@ -276,6 +276,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
|
||||
private volatile IDataPlaneAuthoritySource _dataPlaneAuthority = EmptyDataPlaneAuthoritySource.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// The node-scoped ACL gate (#520 stage 5), or <c>null</c> when none is wired. Consulted by the
|
||||
/// write, alarm-command and HistoryRead seams <b>only while
|
||||
/// <see cref="Security.DataPlaneAuthorizer.Enabled"/> is true</b>; otherwise each keeps its
|
||||
/// server-wide role check and behaviour is identical to before this stage.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null means "not wired" and is treated as not-enabled, not as deny-all. That looks like the wrong
|
||||
/// direction for a security control, but the alternative denies every write on any node that has
|
||||
/// not been given an authorizer — including every existing test host and the admin-only nodes — to
|
||||
/// protect a feature that is off by default. The real fail-closed behaviour lives one level in:
|
||||
/// once an authorizer IS wired and enabled, every unresolvable case denies.
|
||||
/// </remarks>
|
||||
public DataPlaneAuthorizer? Authorizer
|
||||
{
|
||||
get => _authorizer;
|
||||
set => _authorizer = value;
|
||||
}
|
||||
|
||||
private volatile DataPlaneAuthorizer? _authorizer;
|
||||
|
||||
/// <summary>
|
||||
/// True when a wired authorizer is switched on — i.e. when ACL grants, rather than the server-wide
|
||||
/// role set, decide the gated operations.
|
||||
/// </summary>
|
||||
private bool AclEnforced => _authorizer is { Enabled: true };
|
||||
|
||||
/// <summary>
|
||||
/// The upper bound on the bounded over-fetch <see cref="ServeRawPaged"/> uses to page WITHIN an
|
||||
/// oversized "tie cluster" — more raw samples sharing one SourceTimestamp than the client's per-page
|
||||
@@ -1181,17 +1208,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// Resolve the principal the SAME way the SDK's own GetCurrentUserId does, then narrow to the
|
||||
// role-carrying identity T17 attached. Anonymous / non-role-carrying identities ⇒ null ⇒ denied.
|
||||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
|
||||
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
|
||||
// state change and surfaces the status to the client; we never route or mutate.
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
var gate = EvaluateAlarmCommandGate(identity, condition, operation);
|
||||
if (gate is not null) return gate;
|
||||
|
||||
var cmd = new AlarmCommand(
|
||||
AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
|
||||
Operation: operation,
|
||||
User: identity.DisplayName ?? string.Empty,
|
||||
User: AlarmOperatorName(identity!),
|
||||
Comment: comment?.Text,
|
||||
UnshelveAtUtc: unshelveAt);
|
||||
|
||||
@@ -1223,10 +1246,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// Resolve + gate the SAME way HandleAlarmCommand does so native and scripted acks share one authz
|
||||
// contract. Anonymous / non-role-carrying identities ⇒ null ⇒ denied (fail closed, never route).
|
||||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
|
||||
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
var gate = EvaluateAlarmCommandGate(identity, condition, "Acknowledge");
|
||||
if (gate is not null) return gate;
|
||||
|
||||
// Same condition-node-id extraction HandleAlarmCommand uses (condition.NodeId.Identifier?.ToString()):
|
||||
// for a native condition this is the folder-scoped NodeId string the DriverHostActor inverse map
|
||||
@@ -1235,12 +1256,72 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(
|
||||
ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
|
||||
Comment: comment?.Text,
|
||||
OperatorUser: identity.DisplayName ?? string.Empty));
|
||||
OperatorUser: AlarmOperatorName(identity!)));
|
||||
|
||||
// Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one authorization gate both alarm-command paths share — scripted
|
||||
/// (<see cref="HandleAlarmCommand"/>) and native (<see cref="HandleNativeAlarmAck"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Extracted in #520 stage 5 because the two had a copy of the same role check and were about
|
||||
/// to acquire a copy of the same ACL check. They must not drift: an ack that the scripted path
|
||||
/// refuses and the native path allows is a hole whose shape depends on which kind of alarm the
|
||||
/// operator happened to click.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// With ACLs enforced, the Part 9 <c>Acknowledge</c> / <c>Confirm</c> / shelve calls map onto
|
||||
/// their own <see cref="OpcUaOperation"/> members, so a grant CAN distinguish them — unlike the
|
||||
/// role gate this replaces, where one <c>AlarmAck</c> role covered all three. Anything not
|
||||
/// recognised as confirm or shelve is treated as an acknowledge, which is the most restrictive
|
||||
/// reading available: <c>AlarmAcknowledge</c> is the permission an operator authoring "can this
|
||||
/// principal act on alarms at all" grants first.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="identity">The role-carrying identity off the SDK context, or null.</param>
|
||||
/// <param name="condition">The condition the method targets.</param>
|
||||
/// <param name="operation">The Part 9 operation name.</param>
|
||||
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
|
||||
private ServiceResult? EvaluateAlarmCommandGate(
|
||||
RoleCarryingUserIdentity? identity, ConditionState condition, string operation)
|
||||
{
|
||||
if (AclEnforced)
|
||||
{
|
||||
return _authorizer!.Authorize(
|
||||
identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId));
|
||||
}
|
||||
|
||||
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
|
||||
// state change and surfaces the status to the client; we never route or mutate.
|
||||
return identity is null
|
||||
|| !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase)
|
||||
? new ServiceResult(StatusCodes.BadUserAccessDenied)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Maps a Part 9 method name onto the permission it requires.</summary>
|
||||
/// <param name="operation">The Part 9 operation name.</param>
|
||||
/// <returns>The operation to authorize.</returns>
|
||||
internal static OpcUaOperation AlarmOperationFor(string operation) => operation switch
|
||||
{
|
||||
"Confirm" => OpcUaOperation.AlarmConfirm,
|
||||
"OneShotShelve" or "TimedShelve" or "Unshelve" => OpcUaOperation.AlarmShelve,
|
||||
_ => OpcUaOperation.AlarmAcknowledge,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The operator name recorded on an alarm command. Prefers the ACL principal over the SDK display
|
||||
/// name, which for a key-bearing client is the static username every key presents.
|
||||
/// </summary>
|
||||
/// <param name="identity">The authenticated identity.</param>
|
||||
/// <returns>The name to record.</returns>
|
||||
private static string AlarmOperatorName(RoleCarryingUserIdentity identity) =>
|
||||
identity.Principal.Length > 0 ? identity.Principal : identity.DisplayName ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="NodeValueEventHandler"/> attached to a writable equipment-tag variable by
|
||||
/// <see cref="EnsureVariable"/>. The OPC UA SDK invokes it when a client writes the
|
||||
@@ -1284,7 +1365,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
|
||||
var gateway = _nodeWriteGateway;
|
||||
var gate = EvaluateEquipmentWriteGate(identity, gateway is not NullOpcUaNodeWriteGateway);
|
||||
var gatewayWired = gateway is not NullOpcUaNodeWriteGateway;
|
||||
|
||||
// #520 stage 5. With ACLs enforced the scoped decision REPLACES the server-wide WriteOperate role
|
||||
// check — see AclOptions for why chaining them would leave every narrow grant inert. The
|
||||
// gateway-wired check applies either way: it is an availability fact, not an authorization one.
|
||||
var gate = AclEnforced
|
||||
? _authorizer!.Authorize(identity, OpcUaOperation.WriteOperate, node.NodeId, RealmOf(node.NodeId))
|
||||
?? (gatewayWired ? null : new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable"))
|
||||
: EvaluateEquipmentWriteGate(identity, gatewayWired);
|
||||
if (gate is not null) return gate;
|
||||
|
||||
// Item A (synchronous structural fail-fast): reject a structurally-invalid write INLINE — return Bad
|
||||
@@ -1314,7 +1403,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// event can populate ClientUserId. Resolved here off the same identity the gate used (null when the
|
||||
// session is anonymous / carries no role-carrying identity — the gate would already have vetoed, so in
|
||||
// practice non-null on this path, but kept defensive).
|
||||
var clientUserId = identity?.DisplayName;
|
||||
// Prefer the ACL principal: for a key-bearing client DisplayName is the static username every key
|
||||
// presents ("apikey"), so an audit trail built on it cannot tell two consuming systems apart.
|
||||
var clientUserId = identity is { Principal.Length: > 0 } ? identity.Principal : identity?.DisplayName;
|
||||
|
||||
// Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert (off-Lock
|
||||
// continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic value never
|
||||
@@ -2520,6 +2611,67 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// backend throw) never throws out of the batch.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Drops the handles in a HistoryRead batch the calling session is not granted
|
||||
/// <see cref="OpcUaOperation.HistoryRead"/> on, marking each denied slot
|
||||
/// <c>BadUserAccessDenied</c> (#520 stage 5).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Until this stage HistoryRead carried no authorization check whatsoever</b> — any session
|
||||
/// that could open a connection could read the full history of every historized node. It was
|
||||
/// not a regression; it had simply never been gated, because the only two gates in the server
|
||||
/// were the write and alarm role checks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Denial is <b>per node, not per batch</b>: OPC UA Part 11 gives every node in a HistoryRead
|
||||
/// its own result slot, and a client reading ten nodes of which one is off-limits should get
|
||||
/// nine answers and one denial rather than a whole failed request. Failing the batch would also
|
||||
/// leak more than it protects — the client learns exactly which read was refused either way,
|
||||
/// but a batch failure additionally denies data it was entitled to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Returns the original list unchanged when enforcement is off, so the non-ACL path allocates
|
||||
/// nothing and behaves exactly as before.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="context">The SDK context carrying the session identity.</param>
|
||||
/// <param name="nodesToProcess">The pre-filtered handles the base dispatcher built.</param>
|
||||
/// <param name="results">The service-level results list.</param>
|
||||
/// <param name="errors">The service-level errors list.</param>
|
||||
/// <returns>The handles that may proceed.</returns>
|
||||
private List<NodeHandle> AuthorizeHistoryRead(
|
||||
ServerSystemContext context,
|
||||
List<NodeHandle> nodesToProcess,
|
||||
IList<SdkHistoryReadResult> results,
|
||||
IList<ServiceResult> errors)
|
||||
{
|
||||
if (!AclEnforced) return nodesToProcess;
|
||||
|
||||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
|
||||
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
|
||||
|
||||
var allowed = new List<NodeHandle>(nodesToProcess.Count);
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
var veto = _authorizer!.Authorize(
|
||||
identity, OpcUaOperation.HistoryRead, handle.NodeId, RealmOf(handle.NodeId));
|
||||
if (veto is null)
|
||||
{
|
||||
allowed.Add(handle);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set both slots explicitly rather than relying on the base's pre-seeded
|
||||
// BadHistoryOperationUnsupported — "unsupported" would tell the client the node has no history,
|
||||
// which is a different fact from "you may not read it" and sends them debugging the wrong thing.
|
||||
errors[handle.Index] = veto;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadUserAccessDenied };
|
||||
}
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void HistoryReadRawModified(
|
||||
ServerSystemContext context,
|
||||
@@ -2532,6 +2684,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
var session = context.OperationContext?.Session;
|
||||
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
|
||||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||||
{
|
||||
using var deadline = CreateDeadline();
|
||||
@@ -2569,6 +2722,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
// OPC UA ProcessingInterval is a Duration in milliseconds — convert once per batch.
|
||||
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
|
||||
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
|
||||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||||
{
|
||||
using var deadline = CreateDeadline();
|
||||
@@ -2618,6 +2772,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
// Snapshot the requested timestamps once — the same list is read for every node.
|
||||
var timestamps = details.ReqTimes?.ToList() ?? new List<DateTime>();
|
||||
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
|
||||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||||
{
|
||||
using var deadline = CreateDeadline();
|
||||
@@ -2649,6 +2804,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// Snapshot the select clauses once — the same filter projects every node's events.
|
||||
var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();
|
||||
|
||||
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
|
||||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||||
{
|
||||
using var deadline = CreateDeadline();
|
||||
|
||||
@@ -3,6 +3,7 @@ using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
@@ -126,6 +127,22 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the node-scoped ACL gate (#520 stage 5) onto the created <see cref="OtOpcUaNodeManager"/>.
|
||||
/// The host calls this after start; passing <c>null</c> detaches it, which returns the write and
|
||||
/// alarm seams to their server-wide role checks and leaves HistoryRead ungated. No-op (returns
|
||||
/// <c>false</c>) when the node manager has not been created yet, mirroring
|
||||
/// <see cref="SetDataPlaneAuthority"/>.
|
||||
/// </summary>
|
||||
/// <param name="authorizer">The gate, or <c>null</c> to detach.</param>
|
||||
/// <returns><c>true</c> when set on a live node manager; <c>false</c> when none exists yet.</returns>
|
||||
public bool SetDataPlaneAuthorizer(DataPlaneAuthorizer? authorizer)
|
||||
{
|
||||
if (_otOpcUaNodeManager is null) return false;
|
||||
_otOpcUaNodeManager.Authorizer = authorizer;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override MasterNodeManager CreateMasterNodeManager(
|
||||
IServerInternal server, ApplicationConfiguration configuration)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Turns an inbound OPC UA request into a node-scoped ACL decision (#520, stage 5). The one place
|
||||
/// the address space's <see cref="NodeId"/> meets <see cref="IPermissionEvaluator"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Dormant unless <see cref="Enabled"/>.</b> With <c>Security:Acl:Enabled</c> false —
|
||||
/// the default — <see cref="Authorize"/> is never consulted and the node manager keeps its
|
||||
/// server-wide role gates. See <see cref="AclOptions"/> for why the two are alternatives rather
|
||||
/// than a chain.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every failure path denies.</b> No identity, no principal, no cluster identity, a NodeId
|
||||
/// whose identifier is not a string, a trie that was never installed — each returns
|
||||
/// <c>BadUserAccessDenied</c>. That is the only safe direction for a gate, but it also means a
|
||||
/// wiring mistake presents as "everything is denied" rather than "everything is allowed", which
|
||||
/// is loud. The install log at <c>OpcUaPublish</c> is where an operator confirms a trie exists at
|
||||
/// all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Authorization state is built per call, not per session.</b> <see cref="UserAuthorizationState"/>
|
||||
/// was designed for LDAP, where a directory can change group membership out of band and a
|
||||
/// long-lived session must re-resolve on a timer. Under the API-key model nothing can change a
|
||||
/// session's principals out of band — the principal comes from the key, and keys and grants both
|
||||
/// ride the deployment artifact — so the only real invalidation signal is the generation stamp,
|
||||
/// which a per-call construction picks up for free. The cost is one small record per write, alarm
|
||||
/// command or HistoryRead, none of which are hot. <b>The publish path is hot, and it is stage 6's
|
||||
/// problem</b>: that seam needs a per-(session, node) decision cache, not this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A consequence worth stating: because the state is rebuilt each call against the current
|
||||
/// generation, <see cref="UserAuthorizationState.IsStale"/> and
|
||||
/// <see cref="UserAuthorizationState.NeedsRefresh"/> can never fire on this path. They are
|
||||
/// vestigial here rather than load-bearing, and a reader should not conclude from their presence
|
||||
/// that a freshness timer is doing anything.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DataPlaneAuthorizer
|
||||
{
|
||||
private readonly IDataPlaneAuthoritySource _authority;
|
||||
private readonly IPermissionEvaluator _evaluator;
|
||||
private readonly ILogger _log;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="DataPlaneAuthorizer"/> class.</summary>
|
||||
/// <param name="authority">The node's current data-plane authorization inputs.</param>
|
||||
/// <param name="evaluator">The permission evaluator reading the tries built alongside those inputs.</param>
|
||||
/// <param name="enabled">Whether ACL evaluation decides authorization (<c>Security:Acl:Enabled</c>).</param>
|
||||
/// <param name="log">The logger.</param>
|
||||
/// <param name="time">Time provider, injected for testability.</param>
|
||||
public DataPlaneAuthorizer(
|
||||
IDataPlaneAuthoritySource authority,
|
||||
IPermissionEvaluator evaluator,
|
||||
bool enabled,
|
||||
ILogger log,
|
||||
TimeProvider? time = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(authority);
|
||||
ArgumentNullException.ThrowIfNull(evaluator);
|
||||
ArgumentNullException.ThrowIfNull(log);
|
||||
|
||||
_authority = authority;
|
||||
_evaluator = evaluator;
|
||||
_log = log;
|
||||
_time = time ?? TimeProvider.System;
|
||||
Enabled = enabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when ACL grants — rather than the server-wide role set — decide write, alarm-command and
|
||||
/// HistoryRead authorization.
|
||||
/// </summary>
|
||||
public bool Enabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes <paramref name="operation"/> on <paramref name="nodeId"/> for the session behind
|
||||
/// <paramref name="identity"/>.
|
||||
/// </summary>
|
||||
/// <param name="identity">The role-carrying identity off the SDK context, or null when the session
|
||||
/// carries none.</param>
|
||||
/// <param name="operation">The operation being attempted.</param>
|
||||
/// <param name="nodeId">The target node.</param>
|
||||
/// <param name="realm">Which namespace the node was resolved in — passed rather than re-derived so the
|
||||
/// caller's own realm resolution and this decision can never disagree.</param>
|
||||
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
|
||||
public ServiceResult? Authorize(
|
||||
RoleCarryingUserIdentity? identity,
|
||||
OpcUaOperation operation,
|
||||
NodeId nodeId,
|
||||
AddressSpaceRealm realm)
|
||||
{
|
||||
if (identity is null)
|
||||
return Deny(operation, nodeId, principal: null, "the session carries no role-carrying identity");
|
||||
|
||||
if (string.IsNullOrEmpty(identity.Principal))
|
||||
{
|
||||
// An authenticator that predates the principal model, or a code path that built the identity
|
||||
// through the legacy two-argument constructor. Guessing a principal here — from the display
|
||||
// name, say — would silently authorize as whatever the token's username happened to be.
|
||||
return Deny(operation, nodeId, principal: null, "the session resolved no ACL principal");
|
||||
}
|
||||
|
||||
var snapshot = _authority.Current;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(snapshot.ClusterId))
|
||||
{
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
"this node has no cluster identity, so no permission trie exists to evaluate against");
|
||||
}
|
||||
|
||||
// Re-check revocation on every call. The key was verified at session activation, but a deployment
|
||||
// since then may have disabled or expired it — and on a driver-only node the artifact is the ONLY
|
||||
// revocation channel. Without this a revoked key keeps its open session's authority indefinitely,
|
||||
// which on a machine-to-machine data plane means until the client chooses to reconnect.
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
if (identity.ApiKeyId is { Length: > 0 } keyId && snapshot.FindUsableKey(keyId, now) is null)
|
||||
{
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
$"key {keyId} is no longer enabled or has expired in generation {snapshot.GenerationId}");
|
||||
}
|
||||
|
||||
if (!TryBuildScope(nodeId, realm, snapshot.ClusterId, out var scope))
|
||||
{
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
"the node id carries no string identifier, so it addresses no ACL scope");
|
||||
}
|
||||
|
||||
var session = new UserAuthorizationState
|
||||
{
|
||||
SessionId = identity.ApiKeyId ?? identity.Principal,
|
||||
ClusterId = snapshot.ClusterId,
|
||||
Principals = [identity.Principal],
|
||||
MembershipResolvedUtc = now,
|
||||
AuthGenerationId = snapshot.GenerationId,
|
||||
MembershipVersion = snapshot.GenerationId,
|
||||
};
|
||||
|
||||
var decision = _evaluator.Authorize(session, operation, scope);
|
||||
if (decision.IsAllowed) return null;
|
||||
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
$"no grant in generation {snapshot.GenerationId} confers {operation} here");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects an SDK <see cref="NodeId"/> onto the scope the permission trie is keyed by.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <c>s=</c> identifier IS the path in both realms — a RawPath
|
||||
/// (<c>folder…/Driver/Device/group…/Tag</c>) or a UNS path
|
||||
/// (<c>Area/Line/Equipment/EffectiveName</c>) — so the projection is a split, and the depth comes
|
||||
/// out however deep the node actually is. That is why the scope model had to become variable-arity
|
||||
/// in stage 3: a fixed set of level fields could not represent the raw tree at all.
|
||||
/// <para>
|
||||
/// Segments are compared ordinally, matching <c>RawPaths.Comparer</c> and OPC UA NodeId
|
||||
/// identity — <c>Speed</c> and <c>speed</c> are different nodes and must be different scopes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="nodeId">The target node.</param>
|
||||
/// <param name="realm">The namespace the node was resolved in.</param>
|
||||
/// <param name="clusterId">The node's cluster.</param>
|
||||
/// <param name="scope">The resolved scope.</param>
|
||||
/// <returns><see langword="true"/> when a scope could be built.</returns>
|
||||
private static bool TryBuildScope(
|
||||
NodeId nodeId, AddressSpaceRealm realm, string clusterId, out NodeScope scope)
|
||||
{
|
||||
scope = null!;
|
||||
if (nodeId?.Identifier is not string identifier || identifier.Length == 0) return false;
|
||||
|
||||
scope = new NodeScope
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
Kind = realm == AddressSpaceRealm.Raw ? NodeHierarchyKind.Raw : NodeHierarchyKind.Uns,
|
||||
Segments = identifier.Split('/', StringSplitOptions.RemoveEmptyEntries),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private ServiceResult Deny(OpcUaOperation operation, NodeId? nodeId, string? principal, string reason)
|
||||
{
|
||||
// Logged, never returned. The client gets an undifferentiated BadUserAccessDenied so the server
|
||||
// cannot be used to probe which nodes exist or which grants a principal holds; the operator
|
||||
// debugging a denial reads the reason here. Same posture as the authenticator's DenyMessage.
|
||||
_log.LogInformation(
|
||||
"AclGate: denied {Operation} on {NodeId} for principal {Principal} — {Reason}",
|
||||
operation, nodeId, principal ?? "(none)", reason);
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
}
|
||||
@@ -51,18 +51,50 @@ public interface IOpcUaUserAuthenticator
|
||||
|
||||
|
||||
/// <summary>Outcome of a UserName authentication attempt. <see cref="Roles"/> populates the session identity's role set.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ⚠️ <b><see cref="DisplayName"/> is not the principal, and assuming it was is a live trap</b>
|
||||
/// (#520 stage 5). The authenticator passes the principal as the display name, but
|
||||
/// <c>RoleCarryingUserIdentity</c> derives <c>UserIdentity.DisplayName</c> from the presented
|
||||
/// <b>token</b> — which for a key-bearing client is the static username <c>apikey</c>, the same
|
||||
/// for every key in the fleet. So the principal was being dropped on the floor at the identity
|
||||
/// boundary, and the audit <c>ClientUserId</c> on a rejected write recorded <c>apikey</c> for
|
||||
/// everyone. <see cref="Principal"/> carries it explicitly; nothing should infer it from a name.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record OpcUaUserAuthResult(
|
||||
bool Success,
|
||||
string? DisplayName,
|
||||
IReadOnlyList<string> Roles,
|
||||
string? Error)
|
||||
{
|
||||
/// <summary>
|
||||
/// The ACL principal the session authenticates as — the name <c>NodeAcl</c> rows are keyed on.
|
||||
/// Empty on a denial, and on a legacy authenticator that predates the principal model, which the
|
||||
/// ACL gate treats as unauthorized rather than guessing.
|
||||
/// </summary>
|
||||
public string Principal { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Public id of the API key that authenticated, or null for anonymous. Carried so an open session
|
||||
/// can be re-checked against a later deployment that revoked the key.
|
||||
/// </summary>
|
||||
public string? ApiKeyId { get; init; }
|
||||
|
||||
/// <summary>Creates a successful authentication result with the specified display name and roles.</summary>
|
||||
/// <param name="displayName">The display name for the authenticated user.</param>
|
||||
/// <param name="displayName">The display name for the authenticated user — also taken as the principal.</param>
|
||||
/// <param name="roles">The roles to assign to the authenticated user.</param>
|
||||
/// <returns>A successful OpcUaUserAuthResult.</returns>
|
||||
public static OpcUaUserAuthResult Allow(string displayName, IReadOnlyList<string> roles) =>
|
||||
new(true, displayName, roles, null);
|
||||
new(true, displayName, roles, null) { Principal = displayName ?? string.Empty };
|
||||
|
||||
/// <summary>Creates a successful result for a key-bearing client, carrying the key id for revocation re-checks.</summary>
|
||||
/// <param name="principal">The ACL principal the key maps to.</param>
|
||||
/// <param name="roles">The interim server-wide roles.</param>
|
||||
/// <param name="apiKeyId">The public key id that authenticated.</param>
|
||||
/// <returns>A successful OpcUaUserAuthResult.</returns>
|
||||
public static OpcUaUserAuthResult AllowKey(string principal, IReadOnlyList<string> roles, string apiKeyId) =>
|
||||
new(true, principal, roles, null) { Principal = principal, ApiKeyId = apiKeyId };
|
||||
|
||||
/// <summary>Creates a failed authentication result with the specified error message.</summary>
|
||||
/// <param name="error">The error message describing why authentication failed.</param>
|
||||
|
||||
@@ -21,6 +21,20 @@ public sealed class RoleCarryingUserIdentity : UserIdentity
|
||||
/// <param name="token">The user identity token the SDK handed to the impersonation handler.</param>
|
||||
/// <param name="roles">The roles resolved by the authenticator (e.g. <c>ReadOnly</c>, <c>AlarmAck</c>).</param>
|
||||
public RoleCarryingUserIdentity(UserIdentityToken token, IReadOnlyList<string> roles)
|
||||
: this(token, roles, principal: string.Empty, apiKeyId: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance carrying the ACL principal and the authenticating key id alongside
|
||||
/// the roles (#520 stage 5).
|
||||
/// </summary>
|
||||
/// <param name="token">The user identity token the SDK handed to the impersonation handler.</param>
|
||||
/// <param name="roles">The roles resolved by the authenticator.</param>
|
||||
/// <param name="principal">The ACL principal this session authenticates as.</param>
|
||||
/// <param name="apiKeyId">The public id of the key that authenticated, or null for anonymous.</param>
|
||||
public RoleCarryingUserIdentity(
|
||||
UserIdentityToken token, IReadOnlyList<string> roles, string principal, string? apiKeyId)
|
||||
: base(token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(roles);
|
||||
@@ -28,8 +42,30 @@ public sealed class RoleCarryingUserIdentity : UserIdentity
|
||||
// alter this session's granted roles, and so the gate's per-ack `.Contains(...)` runs over a
|
||||
// known-small frozen array.
|
||||
Roles = [.. roles];
|
||||
Principal = principal ?? string.Empty;
|
||||
ApiKeyId = apiKeyId;
|
||||
}
|
||||
|
||||
/// <summary>The roles the authenticator granted this session, used by downstream permission gates.</summary>
|
||||
public IReadOnlyList<string> Roles { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The ACL principal this session authenticates as — the name <c>NodeAcl</c> rows key on, and what
|
||||
/// the permission trie matches. Empty when the authenticator predates the principal model, which
|
||||
/// the ACL gate treats as unauthorized.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>This is NOT <see cref="UserIdentity.DisplayName"/>.</b> The base class derives that from
|
||||
/// the presented token, so for a key-bearing client it is the static username <c>apikey</c> —
|
||||
/// identical for every key in the fleet and useless as an identity. See
|
||||
/// <see cref="OpcUaUserAuthResult.Principal"/>.
|
||||
/// </remarks>
|
||||
public string Principal { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Public id of the API key that authenticated this session, or null for anonymous. Lets an open
|
||||
/// session be re-checked against a later deployment that revoked the key, instead of the key
|
||||
/// remaining usable until the client happens to disconnect.
|
||||
/// </summary>
|
||||
public string? ApiKeyId { get; }
|
||||
}
|
||||
|
||||
@@ -536,26 +536,33 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
var snapshot = _dataPlaneAuthority.Apply(
|
||||
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths);
|
||||
|
||||
// unresolvedScopes counts grants narrower than Cluster whose ScopeId named nothing in this
|
||||
// artifact — a dangling id, or a scope kind with no v3 entity behind it. Those grants will be
|
||||
// dropped when the trie is built, so surfacing the count here is the operator's only warning
|
||||
// that a rule they authored is not going to do anything.
|
||||
var unresolvedScopes = projection.Grants
|
||||
.Where(g => g.ScopeKind != NodeAclScopeKind.Cluster && !string.IsNullOrEmpty(g.ScopeId))
|
||||
.Select(g => new AclScopeRef(g.ScopeKind, g.ScopeId!))
|
||||
.Distinct()
|
||||
.Count(r => !projection.ScopePaths.ContainsKey(r));
|
||||
|
||||
_log.Info(
|
||||
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",
|
||||
snapshot.GenerationId, projection.ClusterId ?? "(single-cluster)", revision[..12],
|
||||
projection.Keys.Count, projection.Grants.Count, projection.ScopePaths.Count);
|
||||
|
||||
if (unresolvedScopes > 0)
|
||||
// Reported BY the trie build (#520 stage 5), not re-derived here. The previous version of this
|
||||
// log counted unresolvable ScopeIds itself and so agreed with the builder on exactly one of its
|
||||
// four drop reasons — a row with a blank ScopeId, a retired scope kind, or a scope path that
|
||||
// does not start at a realm all vanished with the log still reading zero. A dropped row is an
|
||||
// authored rule that can never match, and this is the operator's only warning of it.
|
||||
if (snapshot.DroppedGrants.Count > 0)
|
||||
{
|
||||
_log.Warning(
|
||||
"OpcUaPublish: {Unresolved} ACL scope(s) in generation {Generation} name nothing in the deployed address space — grants on them will be DROPPED, not applied. Check the ScopeId on those rules",
|
||||
unresolvedScopes, snapshot.GenerationId);
|
||||
"OpcUaPublish: {Dropped} ACL rule(s) in generation {Generation} were DROPPED and will never match — {Detail}",
|
||||
snapshot.DroppedGrants.Count, snapshot.GenerationId,
|
||||
string.Join("; ", snapshot.DroppedGrants
|
||||
.Select(d => $"acl {d.NodeAclId} ({d.ScopeKind} '{d.ScopeId}') {d.Reason}")));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(projection.ClusterId) && projection.Grants.Count > 0)
|
||||
{
|
||||
// No cluster identity means no trie was built at all, so nothing can be granted here. Say
|
||||
// so explicitly: with enforcement on, this node denies every write, alarm command and
|
||||
// HistoryRead, and the grant count logged above would otherwise suggest the opposite.
|
||||
_log.Warning(
|
||||
"OpcUaPublish: this node has no cluster identity, so NO permission trie was built for its {Grants} grant(s) — with Security:Acl:Enabled every scoped decision here will be denied",
|
||||
projection.Grants.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -187,6 +187,102 @@ public sealed class DataPlaneAuthorityStateTests
|
||||
CreatedUtc = Now.AddDays(-1),
|
||||
};
|
||||
|
||||
// ───────────────────── #520 stage 5 — the trie is built here, not somewhere else ─────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Applying an artifact builds the permission trie and files it under the SAME generation as the
|
||||
/// snapshot. Keeping the two in one call is the point: as separate wirings, the trie install is the
|
||||
/// one that silently does not run, and an absent trie denies everything while looking like working
|
||||
/// enforcement.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Applying_an_artifact_installs_a_trie_at_the_snapshots_generation()
|
||||
{
|
||||
var sut = new DataPlaneAuthorityState();
|
||||
|
||||
var snapshot = sut.Apply("cluster-a", "rev-1", [], [Acl("acl-1", "p", NodePermissions.Read)], NoScopes);
|
||||
|
||||
sut.Tries.CurrentGenerationId("cluster-a").ShouldBe(snapshot.GenerationId);
|
||||
sut.Tries.GetTrie("cluster-a", snapshot.GenerationId).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Each deployment installs its own generation, so the tries track the snapshots one-for-one.</summary>
|
||||
[Fact]
|
||||
public void Each_revision_installs_its_own_trie_generation()
|
||||
{
|
||||
var sut = new DataPlaneAuthorityState();
|
||||
|
||||
sut.Apply("cluster-a", "rev-1", [], [], NoScopes);
|
||||
sut.Apply("cluster-a", "rev-2", [], [], NoScopes);
|
||||
|
||||
sut.Tries.GetTrie("cluster-a", 1).ShouldNotBeNull();
|
||||
sut.Tries.GetTrie("cluster-a", 2).ShouldNotBeNull();
|
||||
sut.Tries.CurrentGenerationId("cluster-a").ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Old generations are pruned rather than retained forever — an always-on node redeploys
|
||||
/// indefinitely, and every generation held is a whole trie kept alive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Older_trie_generations_are_pruned()
|
||||
{
|
||||
var sut = new DataPlaneAuthorityState();
|
||||
|
||||
for (var i = 1; i <= 6; i++) sut.Apply("cluster-a", $"rev-{i}", [], [], NoScopes);
|
||||
|
||||
sut.Tries.CachedTrieCount.ShouldBe(3);
|
||||
sut.Tries.GetTrie("cluster-a", 6).ShouldNotBeNull();
|
||||
sut.Tries.GetTrie("cluster-a", 1).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node with no cluster identity gets no trie at all. Filing one under a placeholder key would
|
||||
/// be the dangerous alternative: it would let a node whose own cluster is unknown evaluate against
|
||||
/// some other cluster's grants.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_node_with_no_cluster_identity_gets_no_trie()
|
||||
{
|
||||
var sut = new DataPlaneAuthorityState();
|
||||
|
||||
sut.Apply(null, "rev-1", [], [Acl("acl-1", "p", NodePermissions.Read)], NoScopes);
|
||||
|
||||
sut.Tries.CachedTrieCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A grant the build could not place is reported on the snapshot, with its reason. This is what the
|
||||
/// deploy log surfaces to an operator, and it is the only warning that an authored rule is inert —
|
||||
/// a sub-cluster row with no <c>ScopeId</c> names nothing and can never match.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_grant_that_could_not_be_placed_is_reported_on_the_snapshot()
|
||||
{
|
||||
var sut = new DataPlaneAuthorityState();
|
||||
var dangling = Acl("acl-dangling", "p", NodePermissions.Read);
|
||||
dangling.ScopeKind = NodeAclScopeKind.Tag;
|
||||
dangling.ScopeId = "tag-that-is-not-deployed";
|
||||
|
||||
var snapshot = sut.Apply("cluster-a", "rev-1", [], [dangling], NoScopes);
|
||||
|
||||
snapshot.DroppedGrants.ShouldHaveSingleItem();
|
||||
snapshot.DroppedGrants[0].NodeAclId.ShouldBe("acl-dangling");
|
||||
snapshot.DroppedGrants[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
|
||||
}
|
||||
|
||||
/// <summary>A clean artifact reports nothing dropped, so the log stays quiet when it should.</summary>
|
||||
[Fact]
|
||||
public void A_placeable_grant_is_not_reported_as_dropped()
|
||||
{
|
||||
var sut = new DataPlaneAuthorityState();
|
||||
|
||||
var snapshot = sut.Apply(
|
||||
"cluster-a", "rev-1", [], [Acl("acl-1", "p", NodePermissions.Read)], NoScopes);
|
||||
|
||||
snapshot.DroppedGrants.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static NodeAcl Acl(string id, string principal, NodePermissions flags) => new()
|
||||
{
|
||||
NodeAclId = id,
|
||||
|
||||
@@ -44,14 +44,21 @@ public sealed class ArtifactApiKeyStoreTests
|
||||
found.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The FULL grant set is returned, narrow scopes included (#520 stage 5). It used to narrow to
|
||||
/// <c>Cluster</c> here, which was redundant — <see cref="ApiKeyInterimRoleMapper"/> applies exactly
|
||||
/// that filter itself — and became a trap once scope was enforced, because a caller reading
|
||||
/// <c>ApiKeyLookup.Grants</c> as "this principal's grants" got a silently truncated set.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Cluster confinement still happens, just not here: <c>ParseDataPlaneAuthority</c> already kept
|
||||
/// only this node's cluster's rows, which is where this store differs from
|
||||
/// <c>ConfigDbApiKeyStore</c> (that one reads the fleet-wide database and confines it itself; see
|
||||
/// <c>ConfigDbApiKeyStoreTests</c>).
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Only_cluster_scoped_grants_are_returned()
|
||||
public async Task Grants_narrower_than_cluster_are_returned_too()
|
||||
{
|
||||
// Narrower scopes are not yet enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately
|
||||
// under-grants rather than promoting a scoped grant's flags to server-wide authority. Scope kind
|
||||
// is the only narrowing this store performs — cluster confinement already happened in
|
||||
// ParseDataPlaneAuthority, which is where it differs from ConfigDbApiKeyStore (that one filters
|
||||
// on cluster itself; see ConfigDbApiKeyStoreTests).
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply("cluster-a", "rev-1",
|
||||
[Key("abc", "p")],
|
||||
@@ -62,7 +69,32 @@ public sealed class ArtifactApiKeyStoreTests
|
||||
|
||||
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
||||
|
||||
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-cluster");
|
||||
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-cluster", "acl-equipment"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Widening the store did not widen the ROLES. The narrow grant reaches the mapper and still
|
||||
/// confers nothing, so a rule authored as "WriteOperate on one equipment" cannot become
|
||||
/// server-wide write authority on a node with ACL enforcement switched off.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This assertion is the reason the widening is safe, and it lives here rather than only in the
|
||||
/// mapper's own tests because the store is what changed: a test of the mapper alone would have
|
||||
/// stayed green no matter what the store returned.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Widening_the_store_does_not_widen_the_interim_roles()
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply("cluster-a", "rev-1",
|
||||
[Key("abc", "p")],
|
||||
[Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate)],
|
||||
NoScopePaths);
|
||||
|
||||
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
||||
|
||||
found!.Grants.ShouldHaveSingleItem();
|
||||
ApiKeyInterimRoleMapper.Map(found.Grants, "p").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -6,6 +6,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
||||
@@ -146,24 +147,35 @@ public sealed class ConfigDbApiKeyStoreTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Narrower-than-Cluster scopes are still excluded (stage 5 widens this) — the cluster filter is
|
||||
/// an ADDITIONAL narrowing, not a replacement for the scope-kind one.
|
||||
/// Narrower-than-Cluster scopes ARE returned (#520 stage 5 widened this) — the cluster filter is
|
||||
/// now the only narrowing this store performs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The scope-kind filter that used to sit alongside it was redundant: <c>ApiKeyInterimRoleMapper</c>
|
||||
/// applies exactly that filter itself, so removing it changes no role — pinned below — while a
|
||||
/// truncated <c>Grants</c> collection would have quietly misled anything that read it once scope
|
||||
/// started being enforced.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_narrower_scope_in_this_cluster_is_still_excluded()
|
||||
public async Task A_narrower_scope_in_this_cluster_is_returned()
|
||||
{
|
||||
var factory = NewInMemoryDbFactory();
|
||||
Seed(factory,
|
||||
keys: [Key("abc", "p")],
|
||||
acls:
|
||||
[
|
||||
Acl("acl-cluster", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
|
||||
Acl("acl-cluster", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge),
|
||||
Acl("acl-equipment", ThisCluster, "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
|
||||
]);
|
||||
|
||||
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
|
||||
|
||||
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-cluster");
|
||||
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-cluster", "acl-equipment"], ignoreOrder: true);
|
||||
|
||||
// Both grants now reach the mapper, and it still takes flags from the CLUSTER-scoped one only:
|
||||
// AlarmAck is conferred, WriteOperate is not. The two grants carry different flags precisely so
|
||||
// this cannot pass by accident — which is why widening the store is safe with enforcement off.
|
||||
ApiKeyInterimRoleMapper.Map(found.Grants, "p").ShouldBe([OpcUaDataPlaneRoles.AlarmAck]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #520 stage 5 — the node-scoped ACL gate. Every test drives the REAL
|
||||
/// <see cref="DataPlaneAuthorityState"/> → <see cref="PermissionTrieBuilder"/> →
|
||||
/// <see cref="TriePermissionEvaluator"/> chain rather than a fake evaluator, because the defect class
|
||||
/// this stage exists to close is precisely "the pieces are individually correct and nothing connects
|
||||
/// them". A test that stubs the evaluator would pass against a gate wired to an empty trie.
|
||||
/// </summary>
|
||||
public sealed class DataPlaneAuthorizerTests
|
||||
{
|
||||
private const string Cluster = "SITE-A";
|
||||
private const string Principal = "scadabridge";
|
||||
private const string KeyId = "a1b2c3d4e5f60011";
|
||||
|
||||
// ───────────────────────────── the fail-closed edges ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A session with no role-carrying identity is denied. There is no "unauthenticated means
|
||||
/// unrestricted" path: an anonymous session that the host admitted still arrives here as a
|
||||
/// principal, so a null identity means something went wrong upstream.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_session_with_no_identity_is_denied()
|
||||
{
|
||||
var gate = Gate(ClusterGrant(NodePermissions.WriteOperate));
|
||||
|
||||
Veto(gate.Authorize(null, OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An identity carrying no principal is denied even when a grant would otherwise match. This is the
|
||||
/// legacy two-argument <see cref="RoleCarryingUserIdentity"/> constructor, and the safe reading of it
|
||||
/// is "unknown", not "infer one from the display name" — the display name for every key-bearing
|
||||
/// client is the same static username.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_identity_with_no_principal_is_denied()
|
||||
{
|
||||
var gate = Gate(ClusterGrant(NodePermissions.WriteOperate));
|
||||
var identity = new RoleCarryingUserIdentity(new UserNameIdentityToken { UserName = "apikey" }, []);
|
||||
|
||||
identity.Principal.ShouldBeEmpty();
|
||||
Veto(gate.Authorize(identity, OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node that resolved no cluster identity has no trie to evaluate against, so it denies. Inventing
|
||||
/// a placeholder cluster would be the dangerous alternative — it would apply some other cluster's
|
||||
/// grants to a node whose own cluster is unknown.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_node_with_no_cluster_identity_denies_everything()
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(clusterId: null, "rev", [], [ClusterGrant(NodePermissions.WriteOperate)], Scopes());
|
||||
|
||||
var gate = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
||||
|
||||
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A NodeId whose identifier is not a string addresses no ACL scope — every node this server mints
|
||||
/// carries a string path — so it is denied rather than evaluated against an empty segment list,
|
||||
/// which would match a realm-wide grant.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_node_id_with_no_string_identifier_is_denied()
|
||||
{
|
||||
var gate = Gate(ClusterGrant(NodePermissions.WriteOperate));
|
||||
|
||||
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, new NodeId(42u, 2), AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
// ───────────────────────────── the grant actually decides ─────────────────────────────
|
||||
|
||||
/// <summary>A cluster-wide WriteOperate grant authorizes a write anywhere in that cluster.</summary>
|
||||
[Fact]
|
||||
public void A_cluster_grant_authorizes_a_write()
|
||||
{
|
||||
var gate = Gate(ClusterGrant(NodePermissions.WriteOperate));
|
||||
|
||||
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw)
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The permission bit is honoured, not merely the presence of a grant: a read-only principal is
|
||||
/// refused a write on the very node it may read.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_read_grant_does_not_authorize_a_write()
|
||||
{
|
||||
var gate = Gate(ClusterGrant(NodePermissions.Read));
|
||||
|
||||
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The heart of the stage.</b> A grant scoped to one device authorizes a tag beneath it and
|
||||
/// refuses a tag under a sibling device. Before this, a narrow grant conferred nothing at all — it
|
||||
/// was inert — so both of these would have been denied, and a test asserting only the denial would
|
||||
/// have passed against the broken build.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_raw_device_scoped_grant_covers_its_own_tags_and_not_a_siblings()
|
||||
{
|
||||
var grant = new NodeAcl
|
||||
{
|
||||
NodeAclId = "acl-dev1",
|
||||
ClusterId = Cluster,
|
||||
Principal = Principal,
|
||||
ScopeKind = NodeAclScopeKind.RawDevice,
|
||||
ScopeId = "dev-1",
|
||||
PermissionFlags = NodePermissions.WriteOperate,
|
||||
};
|
||||
|
||||
var gate = Gate(grant, Scopes(("dev-1", NodeAclScopeKind.RawDevice, ["raw", "Plant", "Modbus", "dev1"])));
|
||||
|
||||
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw)
|
||||
.ShouldBeNull();
|
||||
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev2/Speed"), AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Segment matching is case-SENSITIVE. Raw sibling names are ordinal-unique, so <c>Speed</c> and
|
||||
/// <c>speed</c> can both exist under one device; folding case would let a grant on either silently
|
||||
/// cover both.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Scope_segments_are_matched_case_sensitively()
|
||||
{
|
||||
var grant = new NodeAcl
|
||||
{
|
||||
NodeAclId = "acl-tag",
|
||||
ClusterId = Cluster,
|
||||
Principal = Principal,
|
||||
ScopeKind = NodeAclScopeKind.Tag,
|
||||
ScopeId = "tag-1",
|
||||
PermissionFlags = NodePermissions.WriteOperate,
|
||||
};
|
||||
|
||||
var gate = Gate(grant, Scopes(("tag-1", NodeAclScopeKind.Tag, ["raw", "Plant", "Modbus", "dev1", "Speed"])));
|
||||
|
||||
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw)
|
||||
.ShouldBeNull();
|
||||
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/speed"), AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The realm is a hard gate at level 1 of the walk: a grant on the raw tree does not reach a UNS
|
||||
/// node that happens to share a path. Blocker #1 is that raw nodes were unrepresentable at all, so
|
||||
/// this pins that both realms now evaluate — and that they stay distinct.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_raw_grant_does_not_reach_a_uns_node_with_the_same_path()
|
||||
{
|
||||
var grant = new NodeAcl
|
||||
{
|
||||
NodeAclId = "acl-raw-realm",
|
||||
ClusterId = Cluster,
|
||||
Principal = Principal,
|
||||
ScopeKind = NodeAclScopeKind.Namespace,
|
||||
ScopeId = NodeAclRealms.Raw,
|
||||
PermissionFlags = NodePermissions.WriteOperate,
|
||||
};
|
||||
|
||||
var gate = Gate(grant, Scopes((NodeAclRealms.Raw, NodeAclScopeKind.Namespace, ["raw"])));
|
||||
|
||||
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Area/Line/Eq/Speed"), AddressSpaceRealm.Raw)
|
||||
.ShouldBeNull();
|
||||
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Area/Line/Eq/Speed"), AddressSpaceRealm.Uns));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Part 9 methods map onto distinct permissions, which the single <c>AlarmAck</c> role they
|
||||
/// replace could not express: a principal granted acknowledge alone cannot shelve.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Alarm_acknowledge_and_shelve_are_separately_grantable()
|
||||
{
|
||||
var gate = Gate(ClusterGrant(NodePermissions.AlarmAcknowledge));
|
||||
var node = RawNode("Plant/Modbus/dev1/Speed");
|
||||
|
||||
gate.Authorize(Identity(), OpcUaOperation.AlarmAcknowledge, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
Veto(gate.Authorize(Identity(), OpcUaOperation.AlarmShelve, node, AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HistoryRead has its own bit — a Read grant is not sufficient. This is the OPC UA Part 11
|
||||
/// separation, and it is the first time HistoryRead has been gated at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void History_read_needs_its_own_permission_not_merely_read()
|
||||
{
|
||||
var node = RawNode("Plant/Modbus/dev1/Speed");
|
||||
|
||||
Veto(Gate(ClusterGrant(NodePermissions.Read))
|
||||
.Authorize(Identity(), OpcUaOperation.HistoryRead, node, AddressSpaceRealm.Raw));
|
||||
Gate(ClusterGrant(NodePermissions.HistoryRead))
|
||||
.Authorize(Identity(), OpcUaOperation.HistoryRead, node, AddressSpaceRealm.Raw)
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ───────────────────────────── revocation reaches an OPEN session ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A deployment that disables a key stops that key's ALREADY-OPEN session, not just future ones.
|
||||
/// Without the per-call re-check a revoked credential keeps its authority until the client happens
|
||||
/// to reconnect, which for a machine-to-machine consumer can be indefinitely — and on a driver-only
|
||||
/// node the artifact is the only revocation channel there is.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Revoking_a_key_denies_its_open_session_on_the_next_deployment()
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
var grant = ClusterGrant(NodePermissions.WriteOperate);
|
||||
var node = RawNode("Plant/Modbus/dev1/Speed");
|
||||
var gate = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
||||
|
||||
state.Apply(Cluster, "rev-1", [Key(enabled: true)], [grant], Scopes());
|
||||
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
|
||||
state.Apply(Cluster, "rev-2", [Key(enabled: false)], [grant], Scopes());
|
||||
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An expired key is refused on the same path, and expiry is read against the injected clock rather
|
||||
/// than wall time so the test pins the comparison rather than waiting for it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_expired_key_denies_its_open_session()
|
||||
{
|
||||
var clock = new FakeTimeProvider(new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc));
|
||||
var state = new DataPlaneAuthorityState();
|
||||
var key = Key(enabled: true);
|
||||
key.ExpiresUtc = new DateTime(2026, 8, 1, 13, 0, 0, DateTimeKind.Utc);
|
||||
state.Apply(Cluster, "rev-1", [key], [ClusterGrant(NodePermissions.WriteOperate)], Scopes());
|
||||
|
||||
var gate = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, clock);
|
||||
var node = RawNode("Plant/Modbus/dev1/Speed");
|
||||
|
||||
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
|
||||
clock.Advance(TimeSpan.FromHours(2));
|
||||
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous carries no key id, so the revocation probe must not fire for it — otherwise every
|
||||
/// anonymous request would be denied by a lookup for a key that was never presented.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_anonymous_session_is_not_denied_by_the_key_revocation_check()
|
||||
{
|
||||
var grant = ClusterGrant(NodePermissions.Read);
|
||||
grant.Principal = OpcUaPrincipals.Anonymous;
|
||||
var gate = Gate(grant);
|
||||
var anonymous = new RoleCarryingUserIdentity(
|
||||
new AnonymousIdentityToken(), [], OpcUaPrincipals.Anonymous, apiKeyId: null);
|
||||
|
||||
gate.Authorize(anonymous, OpcUaOperation.Read, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw)
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ───────────────────────────── the generation stamp ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A deployment that adds a grant takes effect on an open session immediately — the session is
|
||||
/// stamped with the current generation on every call, so it never pins itself to the generation it
|
||||
/// activated under and then has to be bounced to pick an ACL change up.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_grant_added_by_a_later_deployment_reaches_an_open_session()
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
var node = RawNode("Plant/Modbus/dev1/Speed");
|
||||
var gate = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
||||
|
||||
state.Apply(Cluster, "rev-1", [Key(enabled: true)], [], Scopes());
|
||||
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw));
|
||||
|
||||
state.Apply(Cluster, "rev-2", [Key(enabled: true)], [ClusterGrant(NodePermissions.WriteOperate)], Scopes());
|
||||
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// And the reverse: revoking a GRANT (rather than the key) also lands on the open session. Both
|
||||
/// directions matter — an ACL edit that only widens would be the easy half.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_grant_removed_by_a_later_deployment_stops_an_open_session()
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
var node = RawNode("Plant/Modbus/dev1/Speed");
|
||||
var gate = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
||||
|
||||
state.Apply(Cluster, "rev-1", [Key(enabled: true)], [ClusterGrant(NodePermissions.WriteOperate)], Scopes());
|
||||
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
|
||||
state.Apply(Cluster, "rev-2", [Key(enabled: true)], [], Scopes());
|
||||
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
/// <summary>The flag is carried faithfully; the seams read it to decide whether to consult the gate at all.</summary>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void The_enabled_flag_is_reported_as_configured(bool enabled)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance)
|
||||
.Enabled.ShouldBe(enabled);
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private static void Veto(ServiceResult? result)
|
||||
{
|
||||
result.ShouldNotBeNull();
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
private static DataPlaneAuthorizer Gate(
|
||||
NodeAcl grant,
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>? scopePaths = null)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", [Key(enabled: true)], [grant], scopePaths ?? Scopes());
|
||||
return new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
||||
}
|
||||
|
||||
private static RoleCarryingUserIdentity Identity() =>
|
||||
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, KeyId);
|
||||
|
||||
private static NodeId RawNode(string path) => new(path, 2);
|
||||
|
||||
private static NodeAcl ClusterGrant(NodePermissions flags) => new()
|
||||
{
|
||||
NodeAclId = "acl-cluster",
|
||||
ClusterId = Cluster,
|
||||
Principal = Principal,
|
||||
ScopeKind = NodeAclScopeKind.Cluster,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static ApiKey Key(bool enabled) => new()
|
||||
{
|
||||
ApiKeyId = KeyId,
|
||||
Name = "stage5",
|
||||
SecretHash = "hash",
|
||||
Principal = Principal,
|
||||
Enabled = enabled,
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> Scopes(
|
||||
params (string ScopeId, NodeAclScopeKind Kind, string[] Segments)[] scopes)
|
||||
{
|
||||
var map = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
||||
foreach (var (scopeId, kind, segments) in scopes)
|
||||
map[new AclScopeRef(kind, scopeId)] = [new NodeAclPath(segments)];
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <summary>Minimal controllable clock — the suite needs only "now" and "advance".</summary>
|
||||
private sealed class FakeTimeProvider(DateTime utcNow) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #520 stage 5 — enforcement at the node manager's own seams, through a REAL booted
|
||||
/// <see cref="OtOpcUaSdkServer"/> and the SDK's own dispatch. <see cref="DataPlaneAuthorizerTests"/>
|
||||
/// proves the decision; this proves the decision is actually consulted, which is the half that has
|
||||
/// historically gone wrong in this repo (a correct component wired to nothing, or wired behind a
|
||||
/// branch that never runs).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// HistoryRead is the seam exercised end-to-end because it is reachable through the node manager's
|
||||
/// public entry point with no client socket, and because it had <b>no authorization check of any kind</b>
|
||||
/// before this stage — so a green result here is the difference between "any session can read all
|
||||
/// history" and "a grant is required".
|
||||
/// </remarks>
|
||||
public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
{
|
||||
private const string Cluster = "SITE-A";
|
||||
private const string Principal = "scadabridge";
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(
|
||||
Path.GetTempPath(), $"otopcua-acl-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>
|
||||
/// With enforcement OFF, HistoryRead behaves exactly as it always has — no identity is required and
|
||||
/// nothing is denied. This is the flag's whole promise, and it is the assertion that would catch a
|
||||
/// change accidentally enforcing by default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task History_read_is_ungated_while_enforcement_is_off()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistorianDataSource = NullHistorianDataSource.Instance;
|
||||
nm.Authorizer = Authorizer(enabled: false, GrantOn(NodePermissions.HistoryRead));
|
||||
var nodeId = Historized(nm, "Plant/Modbus/dev1/Speed");
|
||||
|
||||
var (_, errors) = ReadRaw(nm, identity: null, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With enforcement ON, an AUTHENTICATED session whose principal holds a grant that does not carry
|
||||
/// the HistoryRead bit is refused — the check the seam never had.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The identity is deliberately real and the grant deliberately non-empty. Denying an anonymous
|
||||
/// context would pass against a gate that only ever checks for a null identity and never consults a
|
||||
/// grant at all, which is most of what could go wrong here.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task History_read_is_denied_without_the_permission_once_enforcement_is_on()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistorianDataSource = NullHistorianDataSource.Instance;
|
||||
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Read));
|
||||
var nodeId = Historized(nm, "Plant/Modbus/dev1/Speed");
|
||||
|
||||
var (results, errors) = ReadRaw(nm, Identity(), nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
// Not BadHistoryOperationUnsupported: "you may not read this" and "this node has no history"
|
||||
// are different facts, and reporting the wrong one sends an operator debugging historisation.
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>A granted principal reads normally — the gate is not simply denying everything.</summary>
|
||||
[Fact]
|
||||
public async Task History_read_succeeds_for_a_granted_principal()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistorianDataSource = NullHistorianDataSource.Instance;
|
||||
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.HistoryRead));
|
||||
var nodeId = Historized(nm, "Plant/Modbus/dev1/Speed");
|
||||
|
||||
var (_, errors) = ReadRaw(nm, Identity(), nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A mixed batch is decided per node: the granted node answers and the ungranted one is denied, in
|
||||
/// the same request. Failing the whole batch would be easier and would also withhold data the client
|
||||
/// was entitled to.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_mixed_batch_denies_only_the_ungranted_node()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistorianDataSource = NullHistorianDataSource.Instance;
|
||||
|
||||
var grant = new NodeAcl
|
||||
{
|
||||
NodeAclId = "acl-dev1",
|
||||
ClusterId = Cluster,
|
||||
Principal = Principal,
|
||||
ScopeKind = NodeAclScopeKind.RawDevice,
|
||||
ScopeId = "dev-1",
|
||||
PermissionFlags = NodePermissions.HistoryRead,
|
||||
};
|
||||
nm.Authorizer = Authorizer(
|
||||
enabled: true, grant,
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||
{
|
||||
[new AclScopeRef(NodeAclScopeKind.RawDevice, "dev-1")] =
|
||||
[new NodeAclPath(["raw", "Plant", "Modbus", "dev1"])],
|
||||
});
|
||||
|
||||
var allowed = Historized(nm, "Plant/Modbus/dev1/Speed");
|
||||
var denied = Historized(nm, "Plant/Modbus/dev2/Speed");
|
||||
|
||||
var (_, errors) = ReadRaw(nm, Identity(), allowed, denied);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── the alarm seam's operation mapping ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Every Part 9 method name the node manager routes maps onto the permission it should require.
|
||||
/// The <c>AlarmAck</c> role this replaces could not distinguish these at all — one role covered
|
||||
/// acknowledge, confirm and shelve — so the mapping is new authority granularity, not a rename.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The inputs are the exact literals the <c>OnAcknowledge</c> / <c>OnShelve</c> / <c>OnEnableDisable</c>
|
||||
/// delegates pass, not invented ones; a mapping that agreed with itself but not with its callers
|
||||
/// would grant the wrong permission and this test would not notice.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("Acknowledge", OpcUaOperation.AlarmAcknowledge)]
|
||||
[InlineData("Confirm", OpcUaOperation.AlarmConfirm)]
|
||||
[InlineData("OneShotShelve", OpcUaOperation.AlarmShelve)]
|
||||
[InlineData("TimedShelve", OpcUaOperation.AlarmShelve)]
|
||||
[InlineData("Unshelve", OpcUaOperation.AlarmShelve)]
|
||||
// AddComment / Enable / Disable have no OpcUaOperation of their own, so they take the most
|
||||
// restrictive available reading — acknowledge is the permission "may act on alarms at all" grants.
|
||||
[InlineData("AddComment", OpcUaOperation.AlarmAcknowledge)]
|
||||
[InlineData("Enable", OpcUaOperation.AlarmAcknowledge)]
|
||||
[InlineData("Disable", OpcUaOperation.AlarmAcknowledge)]
|
||||
public void Part9_method_names_map_to_their_permission(string method, OpcUaOperation expected) =>
|
||||
OtOpcUaNodeManager.AlarmOperationFor(method).ShouldBe(expected);
|
||||
|
||||
// ───────────────────────────── the write seam ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// With enforcement ON, the ACL decision REPLACES the <c>WriteOperate</c> role check — an identity
|
||||
/// carrying NO roles at all writes successfully on the strength of its grant alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the assertion that proves the two gates are alternatives rather than a chain. Run them in
|
||||
/// series and this write is refused, because only a Cluster-scoped grant confers the server-wide
|
||||
/// role — which is exactly the "narrow grants stay inert" outcome the stage exists to end. A test
|
||||
/// that only checked denials would be satisfied by a build that denies everything.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task An_acl_grant_authorizes_a_write_from_a_session_carrying_no_roles()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var gateway = new RecordingWriteGateway();
|
||||
nm.NodeWriteGateway = gateway;
|
||||
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.WriteOperate));
|
||||
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
|
||||
|
||||
var errors = Write(nm, Identity(), nodeId, 42f);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
gateway.Writes.ShouldHaveSingleItem();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// And the converse: a principal whose grant lacks <c>WriteOperate</c> is refused, and — the part
|
||||
/// that matters beyond the status code — <b>nothing is dispatched to the device</b>. A gate that
|
||||
/// denied the client but still routed the write would be worse than no gate at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_denied_write_never_reaches_the_device()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var gateway = new RecordingWriteGateway();
|
||||
nm.NodeWriteGateway = gateway;
|
||||
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Read));
|
||||
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
|
||||
|
||||
var errors = Write(nm, Identity(), nodeId, 42f);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
gateway.Writes.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With enforcement OFF the role gate still stands: a session with no roles is refused exactly as
|
||||
/// before this stage. Pins that the flag's off-path is untouched, in the direction that matters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_roleless_write_is_still_denied_while_enforcement_is_off()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var gateway = new RecordingWriteGateway();
|
||||
nm.NodeWriteGateway = gateway;
|
||||
nm.Authorizer = Authorizer(enabled: false, GrantOn(NodePermissions.WriteOperate));
|
||||
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
|
||||
|
||||
var errors = Write(nm, Identity(), nodeId, 42f);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
gateway.Writes.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private static NodeId Writable(OtOpcUaNodeManager nm, string rawPath)
|
||||
{
|
||||
nm.EnsureVariable(rawPath, parentFolderNodeId: null, displayName: rawPath, dataType: "Float",
|
||||
writable: true, historianTagname: null, realm: AddressSpaceRealm.Raw);
|
||||
return nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId;
|
||||
}
|
||||
|
||||
private static List<ServiceResult> Write(
|
||||
OtOpcUaNodeManager nm, IUserIdentity? identity, NodeId nodeId, object value)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.Write, identity);
|
||||
|
||||
var writes = new List<WriteValue>
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
Value = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good },
|
||||
},
|
||||
};
|
||||
var errors = Enumerable.Repeat<ServiceResult>(null!, writes.Count).ToList();
|
||||
|
||||
nm.Write(context, writes, errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>Records what actually reached the device, so a denial can be shown to route nothing.</summary>
|
||||
private sealed class RecordingWriteGateway : IOpcUaNodeWriteGateway
|
||||
{
|
||||
public List<string> Writes { get; } = [];
|
||||
|
||||
public Task<NodeWriteOutcome> WriteAsync(
|
||||
string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
|
||||
{
|
||||
lock (Writes) Writes.Add(nodeId);
|
||||
return Task.FromResult(new NodeWriteOutcome(true, null));
|
||||
}
|
||||
}
|
||||
|
||||
private static NodeId Historized(OtOpcUaNodeManager nm, string rawPath)
|
||||
{
|
||||
nm.EnsureVariable(rawPath, parentFolderNodeId: null, displayName: rawPath, dataType: "Float",
|
||||
writable: false, historianTagname: rawPath, realm: AddressSpaceRealm.Raw);
|
||||
return nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId;
|
||||
}
|
||||
|
||||
private static (List<SdkHistoryReadResult> Results, List<ServiceResult> Errors) ReadRaw(
|
||||
OtOpcUaNodeManager nm, IUserIdentity? identity, params NodeId[] nodeIds)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity);
|
||||
|
||||
var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList();
|
||||
var results = Enumerable.Repeat<SdkHistoryReadResult>(null!, nodeIds.Length).ToList();
|
||||
var errors = Enumerable.Repeat<ServiceResult>(null!, nodeIds.Length).ToList();
|
||||
|
||||
nm.HistoryRead(
|
||||
context,
|
||||
new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
NumValuesPerNode = 10,
|
||||
IsReadModified = false,
|
||||
},
|
||||
TimestampsToReturn.Both,
|
||||
releaseContinuationPoints: false,
|
||||
nodesToRead,
|
||||
results,
|
||||
errors);
|
||||
|
||||
return (results, errors);
|
||||
}
|
||||
|
||||
private static DataPlaneAuthorizer Authorizer(
|
||||
bool enabled,
|
||||
NodeAcl grant,
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>? scopePaths = null)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", [], [grant],
|
||||
scopePaths ?? new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
||||
return new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance);
|
||||
}
|
||||
|
||||
private static NodeAcl GrantOn(NodePermissions flags) => new()
|
||||
{
|
||||
NodeAclId = "acl-cluster",
|
||||
ClusterId = Cluster,
|
||||
Principal = Principal,
|
||||
ScopeKind = NodeAclScopeKind.Cluster,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static RoleCarryingUserIdentity Identity() =>
|
||||
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: null);
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.AclTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.AclTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Cleans up the PKI root directory.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch (IOException) { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user