Compare commits
2 Commits
9499099028
...
1ccc74005a
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ccc74005a | |||
| f692f6afe4 |
@@ -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