feat(acl): variable-depth scope model + the scopePaths producer (#520 stage 3)
Makes the /raw device tree addressable by an ACL rule, and builds the
scope-path producer PermissionTrieBuilder has accepted since it was written
but which had no caller anywhere.
The plan for this stage said "add NodeHierarchyKind.Raw, whose path is the
RawPath segments Folder/Driver/Device/TagGroup/Tag". That shape is wrong.
RawFolder.ParentRawFolderId and TagGroup.ParentTagGroupId are both
self-referencing, so folders and tag groups each nest without bound and a real
RawPath is folder…/Driver/Device/group…/Tag. NodeScope was fixed-arity and
PermissionTrie.CollectMatches was five hardcoded steps, so the scope model
could not address a real raw tree at all. NodeScope is now {ClusterId, Kind,
Segments} and the walk is a segment loop. The trie STRUCTURE was already
variable-depth (NodeAclPath.Segments is a list, Children is a recursive
dictionary), so only the walker and the scope record had to change.
Four decisions worth keeping:
- The namespace level became the realm. Level 1 gated on NodeScope.NamespaceId,
but v3 has no Namespace entity — the snapshot was retired when the address
space went dual-namespace, and the identifier appears nowhere in production
outside NodeScope. So the level could only ever fail to match, which silently
made every sub-cluster grant unreachable while cluster grants kept working.
It is now a realm token derived from the scope's Kind, not carried on it, so
the walk and the producer cannot disagree about its spelling.
- Provenance comes from the granting row's own ScopeKind, recorded on TrieGrant
at build time. Depth-derived provenance could not name a raw level at all,
since the depth of "Device" varies with folder nesting.
- The trie comparer moved from OrdinalIgnoreCase to Ordinal. Raw sibling-name
uniqueness is ordinal, so Speed and speed can both exist under one device and
a grant on either would have matched both.
- The null-scopePaths "deterministic test mode" is deleted. It planted
unresolvable rows at a direct child of the trie root, a position the walker
cannot reach, so the grant looked placed and could never match — and every
test using that mode proved nothing about production placement. scopePaths is
now required, and a row that does not resolve is dropped with a diagnostic.
A scope resolves to a LIST of paths: a raw tag projected into N equipments is
reachable at N+1 NodeIds and a write through any of them lands on the same
driver reference, so a Tag-scoped grant covering only the raw node would be
bypassable through a UNS one. The lookup is keyed on (ScopeKind, ScopeId), not
ScopeId alone — ScopeId has no FK and no type discriminator, so an id collision
across entity types would hand a grant the wrong node's paths.
Also closed here, all found by review rather than asked for:
- NodeAcl rows had no deploy-time validation of any kind and were not reachable
from DraftValidator at all (DraftSnapshot carried no NodeAcls). They are now
gated for dangling ids, cross-cluster scopes, blank/contradictory ScopeId, the
retired FolderSegment kind, and undefined ordinals.
- ConfigDbApiKeyStore conferred roles from Cluster-scoped grants authored in ANY
cluster, because it filtered on scope kind with no ClusterId predicate while
ArtifactApiKeyStore read an already-cluster-filtered snapshot. On a fused
admin+driver node that was a cross-cluster over-grant. Its doc comment claimed
the two stores matched.
- The two AdminUI ACL banners still said enforcement was LDAP-group -> role
mapping, which stages 1/2 made false in both directions.
- The /apikeys page was never classified in PageAuthorizationGuardTests, so that
test has been red since the key-minting commit merged — I did not run
AdminUI.Tests before merging stage 4.
Falsified rather than assumed: 8 mutations against the trie tests, 5 against the
deploy gate (including one proving a cross-cluster row is not silently
mis-diagnosed as unresolved), a staged red->green for the over-grant regression,
and the realm gate, the comparer and the malformed-path guard each broken
individually to confirm only their own tests go red.
Node ACLs are still not ENFORCED — nothing constructs a NodeScope or calls the
evaluator yet. That is stages 5 and 6; this stage makes the model able to
express what those will enforce.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -78,9 +78,9 @@ string; only what produces that string changes.
|
|||||||
|
|
||||||
| # | #520's blocker | Resolution under this design |
|
| # | #520's blocker | Resolution under this design |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | Raw-realm nodes have no representable `NodeScope` (`NodeHierarchyKind` has one member, `Equipment`) | **Add `NodeHierarchyKind.Raw`**, whose path is the RawPath segments `Folder/Driver/Device/TagGroup/Tag`. `NodeAclScopeKind` already carries `Namespace` and `FolderSegment` — `FolderSegment` becomes matchable for the first time, at raw-folder level. This is the one genuine **design** item, not wiring. |
|
| 1 | Raw-realm nodes have no representable `NodeScope` (`NodeHierarchyKind` has one member, `Equipment`) | **Add `NodeHierarchyKind.Raw`.** ⚠️ **This cell was wrong about the shape** — it described the path as the five-tuple `Folder/Driver/Device/TagGroup/Tag`, but folders and tag groups both self-nest, so the real path is `folder… / Driver / Device / group… / Tag` with unbounded depth on two axes. The fix was therefore not an enum member but a variable-depth `NodeScope` + segment-loop walk. `FolderSegment` was **not** repurposed as the raw-folder level; it is retired, and `RawFolder`/`RawDriver`/`RawDevice`/`RawTagGroup` were appended instead. Correctly identified as the one genuine **design** item. |
|
||||||
| 2 | Session carries roles, not LDAP groups | **Dissolved by D1** — the principal comes from the key, not from a directory. |
|
| 2 | Session carries roles, not LDAP groups | **Dissolved by D1** — the principal comes from the key, not from a directory. |
|
||||||
| 3 | `PermissionTrieBuilder`'s `scopePaths` has no producer | Built node-side from the artifact's UNS hierarchy + raw tree. Note every existing evaluator unit test runs in the no-`scopePaths` "deterministic test mode", so **the production hierarchy path is currently untested** — it needs its own coverage, not just a producer. |
|
| 3 | `PermissionTrieBuilder`'s `scopePaths` has no producer | **Done in stage 3** — `AclScopePathResolver`, built node-side from the artifact's raw + UNS topology (no DB read, so a driver-only node can build it). The coverage note was right in substance but imprecise: six tests *did* pass a `scopePaths` dictionary, but every one hand-wrote it in the test body with exactly the segments the walker expected, so nothing pinned a producer — and there was none to pin. That gap is closed by an artifact → parse → build → walk test. |
|
||||||
| 4 | No session lifecycle / `AuthGenerationId` | Stamp the generation from the artifact `RevisionHash` at session activation; a new deploy invalidates cached decisions. `UserAuthorizationState` already has the freshness predicates. |
|
| 4 | No session lifecycle / `AuthGenerationId` | Stamp the generation from the artifact `RevisionHash` at session activation; a new deploy invalidates cached decisions. `UserAuthorizationState` already has the freshness predicates. |
|
||||||
|
|
||||||
Plus, from the same audit: the node never parses ACLs out of the artifact, and `OtOpcUaNodeManager`
|
Plus, from the same audit: the node never parses ACLs out of the artifact, and `OtOpcUaNodeManager`
|
||||||
@@ -99,11 +99,18 @@ mismatch).
|
|||||||
## Smaller inconsistencies to settle en route
|
## Smaller inconsistencies to settle en route
|
||||||
|
|
||||||
- `NodeAclScopeKind.FolderSegment` is offered in the authoring dropdown but the trie walker has no
|
- `NodeAclScopeKind.FolderSegment` is offered in the authoring dropdown but the trie walker has no
|
||||||
level to match it at — fixed by blocker #1's `Raw` kind.
|
level to match it at — **settled in stage 3, but not the way this line assumed.** It is a retired v2
|
||||||
|
concept (it described the `SystemPlatform`/Galaxy hierarchy v3 deleted), so rather than being given a
|
||||||
|
level it is now explicitly unresolvable, with `RawFolder` as its v3 replacement. It survives in the
|
||||||
|
enum only because the artifact stores the ordinal, so removing it would renumber every later member
|
||||||
|
in every deployed artifact. The dropdown labels it retired, and the deploy gate names it.
|
||||||
|
⚠️ The old claim that the walker could not match it was slightly overstated: `Build` routed it through
|
||||||
|
`Descend` like any other kind, so given a path it *would* have matched and been reported under
|
||||||
|
whatever level it happened to land on. It was unlabellable, not unplaceable.
|
||||||
- `NodePermissions.AlarmRead` has no `OpcUaOperation` mapping — grantable but unreachable.
|
- `NodePermissions.AlarmRead` has no `OpcUaOperation` mapping — grantable but unreachable.
|
||||||
- `NodeAcl.ScopeId` has no FK and no existence check, so scope ids dangle silently.
|
- `NodeAcl.ScopeId` has no FK and no existence check, so scope ids dangle silently.
|
||||||
- `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference `Core` at all — the evaluator is not even
|
- ~~`ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference `Core` at all~~ — **stale**; stage 1 added
|
||||||
on the node manager's compile-time reference graph.
|
the reference. The line read as an open item long after it was closed.
|
||||||
- `NodePermissions.HistoryUpdate` is deliberately in no bundle until HistoryUpdate exists. Leave it.
|
- `NodePermissions.HistoryUpdate` is deliberately in no bundle until HistoryUpdate exists. Leave it.
|
||||||
|
|
||||||
## Stages
|
## Stages
|
||||||
@@ -115,8 +122,55 @@ Each stage is one commit, builds clean, and leaves the tree shippable.
|
|||||||
2. ✅ **Identity cutover.** `ApiKeyOpcUaUserAuthenticator` replaces `LdapOpcUaUserAuthenticator` on the
|
2. ✅ **Identity cutover.** `ApiKeyOpcUaUserAuthenticator` replaces `LdapOpcUaUserAuthenticator` on the
|
||||||
OPC UA data plane; Anonymous resolves to the `anonymous` principal or is rejected per setting.
|
OPC UA data plane; Anonymous resolves to the `anonymous` principal or is rejected per setting.
|
||||||
AdminUI LDAP untouched.
|
AdminUI LDAP untouched.
|
||||||
3. **Raw scope.** *(not started — sequenced after stage 4 at the owner's request)* `NodeHierarchyKind.Raw` + RawPath scope paths; `scopePaths` producer built from the
|
3. **Raw scope.** ✅ **Done.** `NodeHierarchyKind.Raw`, a variable-depth scope model, the `scopePaths`
|
||||||
artifact; coverage for the production hierarchy path that today has none.
|
producer (`AclScopePathResolver`) fed from the artifact, a deploy-time gate for ACL scopes, and the
|
||||||
|
first coverage of the production hierarchy path.
|
||||||
|
|
||||||
|
**It was substantially larger than this line originally implied, because the sketch had the shape of
|
||||||
|
the raw tree wrong.** The plan said the Raw path is the five-tuple
|
||||||
|
`Folder/Driver/Device/TagGroup/Tag`. It is not: `RawFolder.ParentRawFolderId` and
|
||||||
|
`TagGroup.ParentTagGroupId` are both self-referencing, so folders and tag groups each nest without
|
||||||
|
bound and a real RawPath is `folder… / Driver / Device / group… / Tag`. `NodeScope` was fixed-arity
|
||||||
|
(five nullable level fields) and `PermissionTrie.CollectMatches` was five hardcoded steps, so the
|
||||||
|
scope model could not address a real raw tree at all. `NodeScope` is now `{ClusterId, Kind,
|
||||||
|
IReadOnlyList<string> Segments}` and the walk is a segment loop. Good news found en route: the trie
|
||||||
|
*structure* was already variable-depth (`NodeAclPath.Segments` is a list; `PermissionTrieNode.Children`
|
||||||
|
is a recursive dictionary), so only the walker and the scope record had to change.
|
||||||
|
|
||||||
|
Four decisions worth keeping:
|
||||||
|
|
||||||
|
- **The namespace level became the realm.** Level 1 of the walk gated on `NodeScope.NamespaceId`, but
|
||||||
|
v3 **has no `Namespace` entity** — the snapshot was retired when the address space went
|
||||||
|
dual-namespace, and the identifier appeared nowhere in production outside `NodeScope`. So level 1
|
||||||
|
could only ever fail to match, which silently made *every* sub-cluster grant unreachable while
|
||||||
|
cluster grants kept working. It is now the realm token (`raw`/`uns`), derived from the scope's
|
||||||
|
`Kind` by `NodeAclRealms` rather than carried on it, so the walk and the path producer cannot
|
||||||
|
disagree about its spelling. `NodeAclScopeKind.Namespace` becomes meaningful again as "a whole
|
||||||
|
realm".
|
||||||
|
- **Provenance comes from the granting row's `ScopeKind`**, recorded on `TrieGrant` at build time,
|
||||||
|
not from the depth the grant sits at. Depth-derived provenance could not name a raw level at all
|
||||||
|
(the depth of "Device" varies with folder nesting) and misreported any path whose length disagreed
|
||||||
|
with its kind.
|
||||||
|
- **The trie comparer moved from `OrdinalIgnoreCase` to `Ordinal`.** Raw sibling-name uniqueness is
|
||||||
|
ordinal, so `Speed` and `speed` can both exist under one device and a grant on either would have
|
||||||
|
matched both — a silent widening. Case-sensitive is also the fail-closed direction.
|
||||||
|
- **The null-`scopePaths` "deterministic test mode" is deleted.** It planted unresolvable rows at a
|
||||||
|
direct child of the trie root, a position the walker cannot reach — so the grant looked placed and
|
||||||
|
could never match, and every test using that mode proved nothing about production placement. An
|
||||||
|
unresolvable row is now dropped with a diagnostic, and `scopePaths` is required.
|
||||||
|
|
||||||
|
A scope resolves to a **list** of paths, not one: a raw tag projected into N equipments is reachable
|
||||||
|
at N+1 NodeIds and a write through any of them lands on the same driver reference, so a Tag-scoped
|
||||||
|
grant that covered only the raw node would be bypassable through a UNS one. The lookup is keyed on
|
||||||
|
`(ScopeKind, ScopeId)` rather than `ScopeId` alone, because `ScopeId` has no FK and no type
|
||||||
|
discriminator — ids are prefixed only by convention, and an id collision across entity types would
|
||||||
|
hand a grant the wrong node's paths.
|
||||||
|
|
||||||
|
Also closed here: `NodeAcl` rows had **no deploy-time validation of any kind** and were not even
|
||||||
|
reachable from `DraftValidator` (`DraftSnapshot` carried no `NodeAcls` collection). They are now
|
||||||
|
gated for dangling ids, cross-cluster scopes, blank/contradictory `ScopeId`, the retired
|
||||||
|
`FolderSegment` kind, and undefined ordinals. That gate is what substitutes for the ACL scope picker
|
||||||
|
until stage 7 — `ScopeId` is still a free-text box.
|
||||||
4. **Artifact + lifecycle.** ✅ **Done.** `ConfigComposer` snapshots `ApiKeys` alongside the `NodeAcls`
|
4. **Artifact + lifecycle.** ✅ **Done.** `ConfigComposer` snapshots `ApiKeys` alongside the `NodeAcls`
|
||||||
it already carried; `DeploymentArtifact.ParseDataPlaneAuthority` decodes both node-side (grants
|
it already carried; `DeploymentArtifact.ParseDataPlaneAuthority` decodes both node-side (grants
|
||||||
scoped to the node's own cluster, keys not — a key is a fleet-wide credential and the ACL rows are
|
scoped to the node's own cluster, keys not — a key is a fleet-wide credential and the ACL rows are
|
||||||
|
|||||||
@@ -1,18 +1,62 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
/// <summary>ACL scope level. Per <c>acl-design.md</c> §"Scope Hierarchy".</summary>
|
/// <summary>ACL scope level. Per <c>acl-design.md</c> §"Scope Hierarchy".</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ⚠️ <b>Append new members only — never insert.</b> SQL Server stores this as a string
|
||||||
|
/// (<c>nvarchar(16)</c>, so a member name must also stay ≤ 16 characters), but the deployment
|
||||||
|
/// artifact serialises it as the <b>integer ordinal</b> and the node-side decoder reads it back
|
||||||
|
/// with <c>Enum.IsDefined</c>. Inserting a member mid-enum silently re-maps every grant in every
|
||||||
|
/// already-deployed artifact to a different scope level.
|
||||||
|
/// </remarks>
|
||||||
public enum NodeAclScopeKind
|
public enum NodeAclScopeKind
|
||||||
{
|
{
|
||||||
|
/// <summary>The whole cluster — the only kind that carries a null <c>ScopeId</c>.</summary>
|
||||||
Cluster,
|
Cluster,
|
||||||
Namespace,
|
|
||||||
UnsArea,
|
|
||||||
UnsLine,
|
|
||||||
Equipment,
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A folder segment anchored below a namespace (used for FolderPath-scoped tags).
|
/// One whole OPC UA namespace (realm). <c>ScopeId</c> is the realm token <c>raw</c> or
|
||||||
/// Distinguishes folder grants from UNS <see cref="Equipment"/> grants in the
|
/// <c>uns</c>; see <c>NodeAclRealms</c>, which is the single authority for that spelling.
|
||||||
/// <c>AuthorizationDecision.Provenance</c> audit trail and Admin UI diagnostics.
|
/// </summary>
|
||||||
|
Namespace,
|
||||||
|
|
||||||
|
/// <summary>A UNS area. <c>ScopeId</c> is a <c>UnsArea.UnsAreaId</c>.</summary>
|
||||||
|
UnsArea,
|
||||||
|
|
||||||
|
/// <summary>A UNS line. <c>ScopeId</c> is a <c>UnsLine.UnsLineId</c>.</summary>
|
||||||
|
UnsLine,
|
||||||
|
|
||||||
|
/// <summary>A UNS equipment. <c>ScopeId</c> is an <c>Equipment.EquipmentId</c>.</summary>
|
||||||
|
Equipment,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠️ <b>Retired v2 concept — do not author new rows with this kind.</b> It described a folder
|
||||||
|
/// segment under the v2 <c>SystemPlatform</c>/Galaxy hierarchy, which v3 deleted along with the
|
||||||
|
/// <c>WalkSystemPlatform</c> walker. The v3 replacement is <see cref="RawFolder"/>. The member
|
||||||
|
/// survives only because removing it would shift every later ordinal in already-deployed
|
||||||
|
/// artifacts; a row still carrying it resolves no scope path and is reported as an unresolved
|
||||||
|
/// scope by the deploy gate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
FolderSegment,
|
FolderSegment,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single tag. <c>ScopeId</c> is a <c>Tag.TagId</c> — i.e. a <b>raw</b> tag, which is the only
|
||||||
|
/// kind of tag v3 has. Because one raw tag can be projected into several equipments by
|
||||||
|
/// <c>UnsTagReference</c> rows, a Tag-scoped grant resolves to <b>several</b> trie paths: the raw
|
||||||
|
/// node plus every UNS projection of it. That is deliberate — a write routes to the same driver
|
||||||
|
/// reference through either NodeId, so a grant that covered only one of them would be bypassable
|
||||||
|
/// through the other.
|
||||||
|
/// </summary>
|
||||||
Tag,
|
Tag,
|
||||||
|
|
||||||
|
/// <summary>A raw-tree folder. <c>ScopeId</c> is a <c>RawFolder.RawFolderId</c>. (v3; appended.)</summary>
|
||||||
|
RawFolder,
|
||||||
|
|
||||||
|
/// <summary>A raw-tree driver instance. <c>ScopeId</c> is a <c>DriverInstance.DriverInstanceId</c>. (v3; appended.)</summary>
|
||||||
|
RawDriver,
|
||||||
|
|
||||||
|
/// <summary>A raw-tree device. <c>ScopeId</c> is a <c>Device.DeviceId</c>. (v3; appended.)</summary>
|
||||||
|
RawDevice,
|
||||||
|
|
||||||
|
/// <summary>A raw-tree tag group. <c>ScopeId</c> is a <c>TagGroup.TagGroupId</c>. (v3; appended.)</summary>
|
||||||
|
RawTagGroup,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ public sealed class DraftSnapshot
|
|||||||
/// <summary>Gets the list of poll groups.</summary>
|
/// <summary>Gets the list of poll groups.</summary>
|
||||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>ACL grants. Drive the scope-resolution rule: a scope that names nothing deployed is
|
||||||
|
/// silently dropped when the permission trie is built, so the grant has to be caught here instead.</summary>
|
||||||
|
public IReadOnlyList<NodeAcl> NodeAcls { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>Prior Equipment rows (any generation, same cluster) for stability checks.</summary>
|
/// <summary>Prior Equipment rows (any generation, same cluster) for stability checks.</summary>
|
||||||
public IReadOnlyList<Equipment> PriorEquipment { get; init; } = [];
|
public IReadOnlyList<Equipment> PriorEquipment { get; init; } = [];
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ public static class DraftSnapshotFactory
|
|||||||
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
|
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
|
||||||
Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct),
|
Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct),
|
||||||
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
||||||
|
NodeAcls = await db.NodeAcls.AsNoTracking().ToListAsync(ct),
|
||||||
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
|
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
|
||||||
ActiveReservations = await db.ExternalIdReservations
|
ActiveReservations = await db.ExternalIdReservations
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
@@ -42,9 +43,235 @@ public static class DraftValidator
|
|||||||
ValidateEquipReferenceResolution(draft, errors);
|
ValidateEquipReferenceResolution(draft, errors);
|
||||||
ValidateCalculationTags(draft, errors);
|
ValidateCalculationTags(draft, errors);
|
||||||
ValidateSqlConnectionStringNotPersisted(draft, errors);
|
ValidateSqlConnectionStringNotPersisted(draft, errors);
|
||||||
|
ValidateNodeAclScopes(draft, errors);
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #520 stage 3: every <see cref="NodeAcl"/> row's scope must name something the deployed address
|
||||||
|
/// space actually contains.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ACL rows have had <b>no</b> validation anywhere until now, and the failure mode is the worst
|
||||||
|
/// shape a security control can have: <c>AclScopePathResolver</c> returns no path for a scope it
|
||||||
|
/// cannot resolve, the trie builder then drops the grant with a build-time diagnostic, and the
|
||||||
|
/// deploy still seals green. An operator who mistypes a <c>ScopeId</c>, or scopes a grant at an
|
||||||
|
/// entity someone later deleted, gets a row that reads as a live grant in the AdminUI and
|
||||||
|
/// authorizes nothing at runtime. Silently dropping a grant is fail-closed and therefore safe,
|
||||||
|
/// but it is not <em>visible</em> — which is why the check belongs at the deploy gate, where the
|
||||||
|
/// author is still standing in front of the mistake.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The realm tokens are duplicated here, deliberately.</b> <c>NodeAclRealms</c> in
|
||||||
|
/// <c>Core.Authorization</c> is the authority for their spelling, but <c>Core</c> references
|
||||||
|
/// <c>Configuration</c> and not the reverse, so this project cannot reach it. The two namespace
|
||||||
|
/// <em>URIs</em> come from <see cref="V3NodeIds"/> in <c>Commons</c>, which both sides do share;
|
||||||
|
/// only the two short tokens are literals. If a third realm is ever added, it must be added in
|
||||||
|
/// both places — the accepted cost of not inverting the project graph for one gate.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Existence is checked with the same ordinal, untrimmed comparison
|
||||||
|
/// <c>AclScopePathResolver</c>'s dictionary lookups use, so a row this rule passes is a row that
|
||||||
|
/// resolver resolves. The realm check mirrors <c>NodeAclRealms.TryCanonicalize</c> instead
|
||||||
|
/// (trimmed, case-insensitive) for the same reason.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Containment is checked as well as existence.</b> A grant in cluster A naming an entity that
|
||||||
|
/// belongs to cluster B is not the "names nothing" failure — it resolves to a real trie path, and
|
||||||
|
/// so it silently confers authority across a cluster boundary. It gets its own code because the
|
||||||
|
/// remedy is different: an unresolved id is a typo or a deletion, a cross-cluster id is a grant
|
||||||
|
/// authored against the wrong cluster. The lookups are deliberately <b>fleet-wide, not
|
||||||
|
/// cluster-filtered</b> — a per-cluster lookup would make a cross-cluster id present as merely
|
||||||
|
/// unresolved, which is the wrong diagnosis and would leave this check unable to ever fire.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Ownership is not a uniform property. <c>RawFolder</c>, <c>DriverInstance</c> and <c>UnsArea</c>
|
||||||
|
/// carry <c>ClusterId</c>; <c>Device</c>, <c>TagGroup</c>, <c>Tag</c>, <c>UnsLine</c> and
|
||||||
|
/// <c>Equipment</c> do not, and inherit it through their parent. Both chains are at most three
|
||||||
|
/// hops and neither can loop (<c>TagGroup</c> and <c>RawFolder</c> nest, but both reach the
|
||||||
|
/// cluster through a non-nesting link — <c>DeviceId</c> and <c>ClusterId</c> respectively), so
|
||||||
|
/// unlike the resolver's ancestry walks these need no depth guard. A broken chain means the
|
||||||
|
/// owning cluster is unknowable, which is the same state as unresolvable: it reports
|
||||||
|
/// <c>NodeAclScopeUnresolved</c> and never both codes.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void ValidateNodeAclScopes(DraftSnapshot draft, List<ValidationError> errors)
|
||||||
|
{
|
||||||
|
if (draft.NodeAcls.Count == 0) return;
|
||||||
|
|
||||||
|
// One map per ancestry link, built once for the whole pass. Their key sets double as the existence
|
||||||
|
// sets, so "does this id name a row of the kind's type" and "which cluster owns it" cannot disagree.
|
||||||
|
// GroupBy/First rather than ToDictionary: a duplicated id is another rule's business, and throwing
|
||||||
|
// here would take down every remaining check in the same pass.
|
||||||
|
var folderCluster = Link(draft.RawFolders, f => f.RawFolderId, f => f.ClusterId);
|
||||||
|
var driverCluster = Link(draft.DriverInstances, d => d.DriverInstanceId, d => d.ClusterId);
|
||||||
|
var deviceDriver = Link(draft.Devices, d => d.DeviceId, d => d.DriverInstanceId);
|
||||||
|
var groupDevice = Link(draft.TagGroups, g => g.TagGroupId, g => g.DeviceId);
|
||||||
|
var tagDevice = Link(draft.Tags, t => t.TagId, t => t.DeviceId);
|
||||||
|
var areaCluster = Link(draft.UnsAreas, a => a.UnsAreaId, a => a.ClusterId);
|
||||||
|
var lineArea = Link(draft.UnsLines, l => l.UnsLineId, l => l.UnsAreaId);
|
||||||
|
var equipLine = Link(draft.Equipment, e => e.EquipmentId, e => e.UnsLineId);
|
||||||
|
|
||||||
|
static Dictionary<string, string> Link<T>(
|
||||||
|
IReadOnlyList<T> rows, Func<T, string> key, Func<T, string> value) =>
|
||||||
|
rows.GroupBy(key, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => value(g.First()), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Owning cluster, or null when a link in the chain is dangling. Direct-ClusterId kinds bottom out
|
||||||
|
// immediately; the rest hop through their parent.
|
||||||
|
string? ClusterOfDevice(string deviceId) =>
|
||||||
|
deviceDriver.TryGetValue(deviceId, out var driverId) ? driverCluster.GetValueOrDefault(driverId) : null;
|
||||||
|
string? ClusterOfLine(string lineId) =>
|
||||||
|
lineArea.TryGetValue(lineId, out var areaId) ? areaCluster.GetValueOrDefault(areaId) : null;
|
||||||
|
|
||||||
|
foreach (var acl in draft.NodeAcls)
|
||||||
|
{
|
||||||
|
var id = acl.NodeAclId;
|
||||||
|
|
||||||
|
// An ordinal outside the enum comes from a hand-edited row or a schema the code no longer
|
||||||
|
// matches. Every later check switches on the kind, so nothing below it would be meaningful.
|
||||||
|
if (!Enum.IsDefined(acl.ScopeKind))
|
||||||
|
{
|
||||||
|
errors.Add(new("NodeAclScopeKindUndefined",
|
||||||
|
$"NodeAcl '{id}' has ScopeKind ordinal {(int)acl.ScopeKind}, which is not a defined " +
|
||||||
|
"NodeAclScopeKind; the row cannot be evaluated. Re-author it with a valid scope kind.",
|
||||||
|
id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (acl.ScopeKind == NodeAclScopeKind.Cluster)
|
||||||
|
{
|
||||||
|
// Cluster is the one kind with nothing to point at, so a ScopeId here means the author
|
||||||
|
// intended a narrower scope and picked the wrong kind — the grant is cluster-wide either
|
||||||
|
// way, i.e. broader than intended, which is the direction that must never pass silently.
|
||||||
|
if (!string.IsNullOrWhiteSpace(acl.ScopeId))
|
||||||
|
errors.Add(new("NodeAclScopeIdUnexpected",
|
||||||
|
$"NodeAcl '{id}' is Cluster-scoped but carries ScopeId '{acl.ScopeId}'. A Cluster grant " +
|
||||||
|
"covers the whole cluster and ignores ScopeId; clear it, or change ScopeKind to the level " +
|
||||||
|
"the id names.",
|
||||||
|
id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ahead of the missing-ScopeId check on purpose: the retirement is the actionable fact, and
|
||||||
|
// telling someone to supply a ScopeId for a kind that can never resolve sends them the wrong way.
|
||||||
|
if (acl.ScopeKind == NodeAclScopeKind.FolderSegment)
|
||||||
|
{
|
||||||
|
errors.Add(new("NodeAclScopeKindRetired",
|
||||||
|
$"NodeAcl '{id}' uses the retired v2 scope kind FolderSegment, which has no v3 entity behind " +
|
||||||
|
"it and can never resolve, whatever its ScopeId. Re-scope the grant to RawFolder (the v3 " +
|
||||||
|
"replacement) and point ScopeId at the RawFolderId that replaced the old folder segment.",
|
||||||
|
id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(acl.ScopeId))
|
||||||
|
{
|
||||||
|
errors.Add(new("NodeAclScopeIdMissing",
|
||||||
|
$"NodeAcl '{id}' has ScopeKind {acl.ScopeKind} but no ScopeId. Only Cluster grants may omit " +
|
||||||
|
"it; every other kind resolves no scope path without one and the grant is dropped at build " +
|
||||||
|
"time. Set the ScopeId, or change ScopeKind to Cluster.",
|
||||||
|
id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopeId = acl.ScopeId!;
|
||||||
|
|
||||||
|
if (acl.ScopeKind == NodeAclScopeKind.Namespace)
|
||||||
|
{
|
||||||
|
if (CanonicalRealm(scopeId) is null)
|
||||||
|
errors.Add(new("NodeAclNamespaceInvalid",
|
||||||
|
$"NodeAcl '{id}' is Namespace-scoped but ScopeId '{scopeId}' names no OPC UA namespace. " +
|
||||||
|
$"Use '{RealmRaw}' or '{RealmUns}' (or the full URI '{V3NodeIds.RawNamespaceUri}' / " +
|
||||||
|
$"'{V3NodeIds.UnsNamespaceUri}').",
|
||||||
|
id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each arm names the entity type the kind's ScopeId is drawn from, whether a row with that id
|
||||||
|
// exists at all, and which cluster owns it. Switch arms evaluate lazily, so only the matched
|
||||||
|
// kind's chain is walked.
|
||||||
|
var (entity, exists, owner) = acl.ScopeKind switch
|
||||||
|
{
|
||||||
|
NodeAclScopeKind.UnsArea =>
|
||||||
|
("UnsArea", areaCluster.ContainsKey(scopeId), areaCluster.GetValueOrDefault(scopeId)),
|
||||||
|
NodeAclScopeKind.UnsLine =>
|
||||||
|
("UnsLine", lineArea.ContainsKey(scopeId), ClusterOfLine(scopeId)),
|
||||||
|
NodeAclScopeKind.Equipment =>
|
||||||
|
("Equipment", equipLine.ContainsKey(scopeId),
|
||||||
|
equipLine.TryGetValue(scopeId, out var eqLine) ? ClusterOfLine(eqLine) : null),
|
||||||
|
NodeAclScopeKind.Tag =>
|
||||||
|
("Tag", tagDevice.ContainsKey(scopeId),
|
||||||
|
tagDevice.TryGetValue(scopeId, out var tagDev) ? ClusterOfDevice(tagDev) : null),
|
||||||
|
NodeAclScopeKind.RawFolder =>
|
||||||
|
("RawFolder", folderCluster.ContainsKey(scopeId), folderCluster.GetValueOrDefault(scopeId)),
|
||||||
|
NodeAclScopeKind.RawDriver =>
|
||||||
|
("DriverInstance", driverCluster.ContainsKey(scopeId), driverCluster.GetValueOrDefault(scopeId)),
|
||||||
|
NodeAclScopeKind.RawDevice =>
|
||||||
|
("Device", deviceDriver.ContainsKey(scopeId), ClusterOfDevice(scopeId)),
|
||||||
|
NodeAclScopeKind.RawTagGroup =>
|
||||||
|
("TagGroup", groupDevice.ContainsKey(scopeId),
|
||||||
|
groupDevice.TryGetValue(scopeId, out var grpDev) ? ClusterOfDevice(grpDev) : null),
|
||||||
|
|
||||||
|
// Cluster / Namespace / FolderSegment all returned above; the undefined case did too.
|
||||||
|
_ => (string.Empty, true, acl.ClusterId),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
errors.Add(new("NodeAclScopeUnresolved",
|
||||||
|
$"NodeAcl '{id}' is scoped at {acl.ScopeKind} '{scopeId}', which is not a {entity} in this " +
|
||||||
|
"configuration. The grant would be dropped at build time and authorize nothing. Correct the " +
|
||||||
|
"ScopeId, or delete the grant if the entity was removed.",
|
||||||
|
id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (owner is null)
|
||||||
|
{
|
||||||
|
// The row exists but its parent chain is dangling, so it has no place in any cluster's tree
|
||||||
|
// and resolves to no path — the same outcome as a missing row, reported under the same code.
|
||||||
|
errors.Add(new("NodeAclScopeUnresolved",
|
||||||
|
$"NodeAcl '{id}' is scoped at {acl.ScopeKind} '{scopeId}', which exists but whose parent " +
|
||||||
|
$"chain is broken, so no cluster owns it and it resolves to no scope path. Repair the {entity}'s " +
|
||||||
|
"parent reference, or delete the grant.",
|
||||||
|
id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(owner, acl.ClusterId, StringComparison.Ordinal))
|
||||||
|
errors.Add(new("NodeAclScopeCrossCluster",
|
||||||
|
$"NodeAcl '{id}' is a grant in cluster '{acl.ClusterId}' but is scoped at {acl.ScopeKind} " +
|
||||||
|
$"'{scopeId}', which belongs to cluster '{owner}'. The id is real, so the grant would resolve " +
|
||||||
|
"and confer authority across a cluster boundary. Re-author the grant under cluster " +
|
||||||
|
$"'{owner}', or point ScopeId at the matching {entity} in '{acl.ClusterId}'.",
|
||||||
|
id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Realm tokens — see ValidateNodeAclScopes for why these are duplicated rather than referenced.
|
||||||
|
// Core.Authorization.NodeAclRealms is the authority; keep the two in step.
|
||||||
|
private const string RealmRaw = "raw";
|
||||||
|
private const string RealmUns = "uns";
|
||||||
|
|
||||||
|
/// <summary>Mirror of <c>NodeAclRealms.TryCanonicalize</c>: accepts either short realm token or either
|
||||||
|
/// full namespace URI, trimmed and case-insensitive. Null when the id names no realm.</summary>
|
||||||
|
private static string? CanonicalRealm(string scopeId)
|
||||||
|
{
|
||||||
|
var trimmed = scopeId.Trim();
|
||||||
|
|
||||||
|
if (string.Equals(trimmed, RealmRaw, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(trimmed, V3NodeIds.RawNamespaceUri, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return RealmRaw;
|
||||||
|
|
||||||
|
if (string.Equals(trimmed, RealmUns, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(trimmed, V3NodeIds.UnsNamespaceUri, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return RealmUns;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
|
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
|
||||||
/// at the deploy gate (Gitea #498).
|
/// at the deploy gate (Gitea #498).
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies a scope for path lookup. Keyed on the kind <b>as well as</b> the id.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The kind is part of the key because <c>NodeAcl.ScopeId</c> is a bare string with no foreign key
|
||||||
|
/// and no type discriminator of its own — ids are prefixed by convention (<c>RF…</c>, <c>EQ…</c>)
|
||||||
|
/// but nothing enforces it. Keying on the id alone would let a <c>DeviceId</c> that happened to
|
||||||
|
/// equal a <c>UnsAreaId</c> hand a grant the other entity's paths, which is a silent
|
||||||
|
/// wrong-node authorization rather than a visible error.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Kind">The authored scope kind.</param>
|
||||||
|
/// <param name="ScopeId">The authored scope id.</param>
|
||||||
|
public readonly record struct AclScopeRef(NodeAclScopeKind Kind, string ScopeId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produces the <c>scopePaths</c> lookup <see cref="PermissionTrieBuilder.Build"/> consumes: for each
|
||||||
|
/// ACL scope, the trie path(s) at which a grant on it must be planted (#520 stage 3).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Pure and input-shape agnostic, in the same spirit as <see cref="RawPathResolver"/>: callers
|
||||||
|
/// build the ancestry maps from whatever they hold — EF entities on the authoring / deploy-gate
|
||||||
|
/// side, artifact JSON on the node side — so both sides produce identical paths without sharing a
|
||||||
|
/// database. That matters because a driver-only node has no ConfigDb at all (per-cluster mesh
|
||||||
|
/// Phase 4), so anything requiring a DB read node-side is disqualified outright.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Raw tag paths are delegated to <see cref="RawPathResolver"/> rather than recomputed, so an ACL
|
||||||
|
/// path and the RawPath that forms the node's actual NodeId cannot drift apart. Container paths
|
||||||
|
/// (folder / driver / device / group) are composed here from the same chains in the same order,
|
||||||
|
/// which keeps every container path a true prefix of the tags beneath it — the property the trie
|
||||||
|
/// walk depends on for a container grant to cascade.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A broken or dangling link yields <b>no path</b> for that scope, never a throw: the grant is then
|
||||||
|
/// dropped by the builder with an <see cref="PermissionTrieBuildDiagnosticReason.UnresolvedScope"/>
|
||||||
|
/// diagnostic. Fail-closed, and consistent with the resolver family's "a malformed artifact drops
|
||||||
|
/// one entry rather than faulting the whole decode" rule.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AclScopePathResolver
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<string, (string? ParentId, string Name)> _folders;
|
||||||
|
private readonly IReadOnlyDictionary<string, (string? RawFolderId, string Name)> _drivers;
|
||||||
|
private readonly IReadOnlyDictionary<string, (string DriverInstanceId, string Name)> _devices;
|
||||||
|
private readonly IReadOnlyDictionary<string, (string? ParentId, string DeviceId, string Name)> _groups;
|
||||||
|
private readonly IReadOnlyDictionary<string, (string DeviceId, string? TagGroupId, string Name)> _tags;
|
||||||
|
private readonly IReadOnlyDictionary<string, string> _areas;
|
||||||
|
private readonly IReadOnlyDictionary<string, (string UnsAreaId, string Name)> _lines;
|
||||||
|
private readonly IReadOnlyDictionary<string, (string UnsLineId, string Name)> _equipment;
|
||||||
|
private readonly IReadOnlyList<(string EquipmentId, string TagId, string EffectiveName)> _references;
|
||||||
|
private readonly RawPathResolver _rawPaths;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new <see cref="AclScopePathResolver"/> over the deployed topology.</summary>
|
||||||
|
/// <param name="folders"><c>RawFolderId → (ParentRawFolderId?, Name)</c>.</param>
|
||||||
|
/// <param name="drivers"><c>DriverInstanceId → (RawFolderId?, Name)</c>.</param>
|
||||||
|
/// <param name="devices"><c>DeviceId → (DriverInstanceId, Name)</c>.</param>
|
||||||
|
/// <param name="groups"><c>TagGroupId → (ParentTagGroupId?, DeviceId, Name)</c>.</param>
|
||||||
|
/// <param name="tags"><c>TagId → (DeviceId, TagGroupId?, Name)</c>.</param>
|
||||||
|
/// <param name="areas"><c>UnsAreaId → Name</c>.</param>
|
||||||
|
/// <param name="lines"><c>UnsLineId → (UnsAreaId, Name)</c>.</param>
|
||||||
|
/// <param name="equipment"><c>EquipmentId → (UnsLineId, Name)</c>.</param>
|
||||||
|
/// <param name="references">The UNS projections: <c>(EquipmentId, TagId, EffectiveName)</c>.</param>
|
||||||
|
public AclScopePathResolver(
|
||||||
|
IReadOnlyDictionary<string, (string? ParentId, string Name)> folders,
|
||||||
|
IReadOnlyDictionary<string, (string? RawFolderId, string Name)> drivers,
|
||||||
|
IReadOnlyDictionary<string, (string DriverInstanceId, string Name)> devices,
|
||||||
|
IReadOnlyDictionary<string, (string? ParentId, string DeviceId, string Name)> groups,
|
||||||
|
IReadOnlyDictionary<string, (string DeviceId, string? TagGroupId, string Name)> tags,
|
||||||
|
IReadOnlyDictionary<string, string> areas,
|
||||||
|
IReadOnlyDictionary<string, (string UnsAreaId, string Name)> lines,
|
||||||
|
IReadOnlyDictionary<string, (string UnsLineId, string Name)> equipment,
|
||||||
|
IReadOnlyList<(string EquipmentId, string TagId, string EffectiveName)> references)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(folders);
|
||||||
|
ArgumentNullException.ThrowIfNull(drivers);
|
||||||
|
ArgumentNullException.ThrowIfNull(devices);
|
||||||
|
ArgumentNullException.ThrowIfNull(groups);
|
||||||
|
ArgumentNullException.ThrowIfNull(tags);
|
||||||
|
ArgumentNullException.ThrowIfNull(areas);
|
||||||
|
ArgumentNullException.ThrowIfNull(lines);
|
||||||
|
ArgumentNullException.ThrowIfNull(equipment);
|
||||||
|
ArgumentNullException.ThrowIfNull(references);
|
||||||
|
|
||||||
|
_folders = folders;
|
||||||
|
_drivers = drivers;
|
||||||
|
_devices = devices;
|
||||||
|
_groups = groups;
|
||||||
|
_tags = tags;
|
||||||
|
_areas = areas;
|
||||||
|
_lines = lines;
|
||||||
|
_equipment = equipment;
|
||||||
|
_references = references;
|
||||||
|
|
||||||
|
_rawPaths = new RawPathResolver(
|
||||||
|
folders,
|
||||||
|
drivers,
|
||||||
|
devices,
|
||||||
|
groups.ToDictionary(kv => kv.Key, kv => (kv.Value.ParentId, kv.Value.Name), StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve the trie path(s) for every distinct scope named by <paramref name="rows"/>. Scopes that
|
||||||
|
/// resolve to nothing are absent from the result, which is what makes the builder drop them.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rows">The ACL rows whose scopes need resolving.</param>
|
||||||
|
/// <returns>The <c>scopePaths</c> lookup.</returns>
|
||||||
|
public IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> Resolve(IReadOnlyList<NodeAcl> rows)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(rows);
|
||||||
|
|
||||||
|
var result = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (row.ScopeKind == NodeAclScopeKind.Cluster || string.IsNullOrEmpty(row.ScopeId)) continue;
|
||||||
|
|
||||||
|
var key = new AclScopeRef(row.ScopeKind, row.ScopeId);
|
||||||
|
if (result.ContainsKey(key)) continue;
|
||||||
|
|
||||||
|
var paths = ResolveOne(row.ScopeKind, row.ScopeId);
|
||||||
|
if (paths.Count > 0) result[key] = paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolve one scope to its trie path(s). Empty when the scope names nothing deployed.</summary>
|
||||||
|
/// <param name="kind">The scope kind.</param>
|
||||||
|
/// <param name="scopeId">The scope id.</param>
|
||||||
|
/// <returns>The resolved paths, possibly empty.</returns>
|
||||||
|
public IReadOnlyList<NodeAclPath> ResolveOne(NodeAclScopeKind kind, string scopeId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(scopeId)) return [];
|
||||||
|
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case NodeAclScopeKind.Namespace:
|
||||||
|
var realm = NodeAclRealms.TryCanonicalize(scopeId);
|
||||||
|
return realm is null ? [] : [new NodeAclPath([realm])];
|
||||||
|
|
||||||
|
case NodeAclScopeKind.UnsArea:
|
||||||
|
return _areas.TryGetValue(scopeId, out var areaName)
|
||||||
|
? [UnsPath([areaName])]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
case NodeAclScopeKind.UnsLine:
|
||||||
|
return TryUnsLineSegments(scopeId) is { } lineSegs ? [UnsPath(lineSegs)] : [];
|
||||||
|
|
||||||
|
case NodeAclScopeKind.Equipment:
|
||||||
|
return TryEquipmentSegments(scopeId) is { } eqSegs ? [UnsPath(eqSegs)] : [];
|
||||||
|
|
||||||
|
case NodeAclScopeKind.Tag:
|
||||||
|
return ResolveTag(scopeId);
|
||||||
|
|
||||||
|
case NodeAclScopeKind.RawFolder:
|
||||||
|
return TryFolderChain(scopeId) is { } folderSegs ? [RawPath(folderSegs)] : [];
|
||||||
|
|
||||||
|
case NodeAclScopeKind.RawDriver:
|
||||||
|
return TryDriverSegments(scopeId) is { } driverSegs ? [RawPath(driverSegs)] : [];
|
||||||
|
|
||||||
|
case NodeAclScopeKind.RawDevice:
|
||||||
|
return TryDeviceSegments(scopeId) is { } deviceSegs ? [RawPath(deviceSegs)] : [];
|
||||||
|
|
||||||
|
case NodeAclScopeKind.RawTagGroup:
|
||||||
|
return TryGroupSegments(scopeId) is { } groupSegs ? [RawPath(groupSegs)] : [];
|
||||||
|
|
||||||
|
// Cluster never reaches here (the caller short-circuits it), and FolderSegment is the retired
|
||||||
|
// v2 kind with no v3 entity behind it — deliberately unresolvable, so an operator who still
|
||||||
|
// has such a row gets a diagnostic naming it rather than a grant that quietly does nothing.
|
||||||
|
case NodeAclScopeKind.Cluster:
|
||||||
|
case NodeAclScopeKind.FolderSegment:
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<NodeAclPath> ResolveTag(string tagId)
|
||||||
|
{
|
||||||
|
var paths = new List<NodeAclPath>();
|
||||||
|
|
||||||
|
if (_tags.TryGetValue(tagId, out var tag))
|
||||||
|
{
|
||||||
|
var rawPath = _rawPaths.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
|
||||||
|
if (rawPath is not null)
|
||||||
|
paths.Add(RawPath(rawPath.Split(RawPaths.Separator)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every UNS projection of this raw tag. A tag referenced by three equipments is reachable at four
|
||||||
|
// NodeIds, and a write through any of them lands on the same driver reference — so a Tag-scoped
|
||||||
|
// grant has to cover all four or it is bypassable through whichever one it missed.
|
||||||
|
foreach (var reference in _references)
|
||||||
|
{
|
||||||
|
if (!string.Equals(reference.TagId, tagId, StringComparison.Ordinal)) continue;
|
||||||
|
if (TryEquipmentSegments(reference.EquipmentId) is not { } equipSegs) continue;
|
||||||
|
paths.Add(UnsPath([.. equipSegs, reference.EffectiveName]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[]? TryUnsLineSegments(string lineId)
|
||||||
|
{
|
||||||
|
if (!_lines.TryGetValue(lineId, out var line)) return null;
|
||||||
|
if (!_areas.TryGetValue(line.UnsAreaId, out var areaName)) return null;
|
||||||
|
return [areaName, line.Name];
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[]? TryEquipmentSegments(string equipmentId)
|
||||||
|
{
|
||||||
|
if (!_equipment.TryGetValue(equipmentId, out var equip)) return null;
|
||||||
|
if (TryUnsLineSegments(equip.UnsLineId) is not { } lineSegs) return null;
|
||||||
|
return [.. lineSegs, equip.Name];
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[]? TryFolderChain(string? leafFolderId)
|
||||||
|
{
|
||||||
|
if (leafFolderId is null) return [];
|
||||||
|
|
||||||
|
var chain = new List<string>();
|
||||||
|
var current = leafFolderId;
|
||||||
|
var guard = 0;
|
||||||
|
|
||||||
|
// Depth-bounded like RawPathResolver's own walks: this is a corrupt-parent-cycle guard, not a
|
||||||
|
// schema limit — raw folder nesting is unbounded by design.
|
||||||
|
while (current is not null)
|
||||||
|
{
|
||||||
|
if (++guard > MaxAncestryDepth) return null;
|
||||||
|
if (!_folders.TryGetValue(current, out var folder)) return null;
|
||||||
|
chain.Add(folder.Name);
|
||||||
|
current = folder.ParentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.Reverse();
|
||||||
|
return [.. chain];
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[]? TryDriverSegments(string driverInstanceId)
|
||||||
|
{
|
||||||
|
if (!_drivers.TryGetValue(driverInstanceId, out var driver)) return null;
|
||||||
|
if (TryFolderChain(driver.RawFolderId) is not { } folderSegs) return null;
|
||||||
|
return [.. folderSegs, driver.Name];
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[]? TryDeviceSegments(string deviceId)
|
||||||
|
{
|
||||||
|
if (!_devices.TryGetValue(deviceId, out var device)) return null;
|
||||||
|
if (TryDriverSegments(device.DriverInstanceId) is not { } driverSegs) return null;
|
||||||
|
return [.. driverSegs, device.Name];
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[]? TryGroupSegments(string tagGroupId)
|
||||||
|
{
|
||||||
|
if (!_groups.TryGetValue(tagGroupId, out var group)) return null;
|
||||||
|
if (TryDeviceSegments(group.DeviceId) is not { } deviceSegs) return null;
|
||||||
|
|
||||||
|
var chain = new List<string>();
|
||||||
|
string? current = tagGroupId;
|
||||||
|
var guard = 0;
|
||||||
|
|
||||||
|
while (current is not null)
|
||||||
|
{
|
||||||
|
if (++guard > MaxAncestryDepth) return null;
|
||||||
|
if (!_groups.TryGetValue(current, out var node)) return null;
|
||||||
|
chain.Add(node.Name);
|
||||||
|
current = node.ParentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.Reverse();
|
||||||
|
return [.. deviceSegs, .. chain];
|
||||||
|
}
|
||||||
|
|
||||||
|
// No params-array overloads here: string[] binds to both params and IEnumerable, and the resulting
|
||||||
|
// ambiguity would be resolved silently by overload rules rather than by intent.
|
||||||
|
private static NodeAclPath RawPath(IEnumerable<string> segments) =>
|
||||||
|
new([NodeAclRealms.Raw, .. segments]);
|
||||||
|
|
||||||
|
private static NodeAclPath UnsPath(IEnumerable<string> segments) =>
|
||||||
|
new([NodeAclRealms.Uns, .. segments]);
|
||||||
|
|
||||||
|
private const int MaxAncestryDepth = 256;
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
public sealed class DataPlaneAuthoritySnapshot
|
public sealed class DataPlaneAuthoritySnapshot
|
||||||
{
|
{
|
||||||
/// <summary>The state of a node that has not yet applied an artifact. Authenticates nothing.</summary>
|
/// <summary>The state of a node that has not yet applied an artifact. Authenticates nothing.</summary>
|
||||||
public static readonly DataPlaneAuthoritySnapshot Empty = Create(null, string.Empty, 0, [], []);
|
public static readonly DataPlaneAuthoritySnapshot Empty =
|
||||||
|
Create(null, string.Empty, 0, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
||||||
|
|
||||||
private DataPlaneAuthoritySnapshot(
|
private DataPlaneAuthoritySnapshot(
|
||||||
string? clusterId,
|
string? clusterId,
|
||||||
@@ -30,6 +31,7 @@ public sealed class DataPlaneAuthoritySnapshot
|
|||||||
long generationId,
|
long generationId,
|
||||||
IReadOnlyList<ApiKey> keys,
|
IReadOnlyList<ApiKey> keys,
|
||||||
IReadOnlyList<NodeAcl> grants,
|
IReadOnlyList<NodeAcl> grants,
|
||||||
|
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||||
IReadOnlyDictionary<string, ApiKey> keysById,
|
IReadOnlyDictionary<string, ApiKey> keysById,
|
||||||
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal)
|
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal)
|
||||||
{
|
{
|
||||||
@@ -38,6 +40,7 @@ public sealed class DataPlaneAuthoritySnapshot
|
|||||||
GenerationId = generationId;
|
GenerationId = generationId;
|
||||||
Keys = keys;
|
Keys = keys;
|
||||||
Grants = grants;
|
Grants = grants;
|
||||||
|
ScopePaths = scopePaths;
|
||||||
_keysById = keysById;
|
_keysById = keysById;
|
||||||
_grantsByPrincipal = grantsByPrincipal;
|
_grantsByPrincipal = grantsByPrincipal;
|
||||||
}
|
}
|
||||||
@@ -66,6 +69,13 @@ public sealed class DataPlaneAuthoritySnapshot
|
|||||||
/// <summary>Every ACL grant scoped to this node's cluster.</summary>
|
/// <summary>Every ACL grant scoped to this node's cluster.</summary>
|
||||||
public IReadOnlyList<NodeAcl> Grants { get; }
|
public IReadOnlyList<NodeAcl> Grants { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where each grant's scope sits in the address space, resolved from the same artifact the grants
|
||||||
|
/// came from. Feeds <see cref="PermissionTrieBuilder.Build"/>; a scope absent here has no
|
||||||
|
/// deployed node and its grant is dropped rather than planted somewhere unreachable.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths { get; }
|
||||||
|
|
||||||
/// <summary>True once a real artifact has been applied.</summary>
|
/// <summary>True once a real artifact has been applied.</summary>
|
||||||
public bool HasArtifact => GenerationId > 0;
|
public bool HasArtifact => GenerationId > 0;
|
||||||
|
|
||||||
@@ -101,17 +111,20 @@ public sealed class DataPlaneAuthoritySnapshot
|
|||||||
/// <param name="generationId">The node-local generation stamp.</param>
|
/// <param name="generationId">The node-local generation stamp.</param>
|
||||||
/// <param name="keys">The API keys carried by the artifact.</param>
|
/// <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="grants">The ACL grants for this node's cluster.</param>
|
||||||
|
/// <param name="scopePaths">Where each grant's scope sits in the address space.</param>
|
||||||
/// <returns>The built snapshot.</returns>
|
/// <returns>The built snapshot.</returns>
|
||||||
public static DataPlaneAuthoritySnapshot Create(
|
public static DataPlaneAuthoritySnapshot Create(
|
||||||
string? clusterId,
|
string? clusterId,
|
||||||
string revisionHash,
|
string revisionHash,
|
||||||
long generationId,
|
long generationId,
|
||||||
IReadOnlyList<ApiKey> keys,
|
IReadOnlyList<ApiKey> keys,
|
||||||
IReadOnlyList<NodeAcl> grants)
|
IReadOnlyList<NodeAcl> grants,
|
||||||
|
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(revisionHash);
|
ArgumentNullException.ThrowIfNull(revisionHash);
|
||||||
ArgumentNullException.ThrowIfNull(keys);
|
ArgumentNullException.ThrowIfNull(keys);
|
||||||
ArgumentNullException.ThrowIfNull(grants);
|
ArgumentNullException.ThrowIfNull(grants);
|
||||||
|
ArgumentNullException.ThrowIfNull(scopePaths);
|
||||||
|
|
||||||
// OrdinalIgnoreCase throughout: key ids are minted as lowercase hex, but a client hand-copying a
|
// OrdinalIgnoreCase throughout: key ids are minted as lowercase hex, but a client hand-copying a
|
||||||
// key out of the AdminUI can upper-case it, and the principal comparison must agree with the
|
// key out of the AdminUI can upper-case it, and the principal comparison must agree with the
|
||||||
@@ -140,7 +153,8 @@ public sealed class DataPlaneAuthoritySnapshot
|
|||||||
kv => (IReadOnlyList<NodeAcl>)kv.Value,
|
kv => (IReadOnlyList<NodeAcl>)kv.Value,
|
||||||
StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
return new DataPlaneAuthoritySnapshot(clusterId, revisionHash, generationId, keys, grants, byId, frozen);
|
return new DataPlaneAuthoritySnapshot(
|
||||||
|
clusterId, revisionHash, generationId, keys, grants, scopePaths, byId, frozen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,12 +193,14 @@ public interface IDataPlaneAuthoritySink
|
|||||||
/// <param name="revisionHash">SHA-256 of the artifact bytes.</param>
|
/// <param name="revisionHash">SHA-256 of the artifact bytes.</param>
|
||||||
/// <param name="keys">The API keys the artifact carries.</param>
|
/// <param name="keys">The API keys the artifact carries.</param>
|
||||||
/// <param name="grants">The ACL grants for this node's cluster.</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>
|
||||||
/// <returns>The installed snapshot.</returns>
|
/// <returns>The installed snapshot.</returns>
|
||||||
DataPlaneAuthoritySnapshot Apply(
|
DataPlaneAuthoritySnapshot Apply(
|
||||||
string? clusterId,
|
string? clusterId,
|
||||||
string revisionHash,
|
string revisionHash,
|
||||||
IReadOnlyList<ApiKey> keys,
|
IReadOnlyList<ApiKey> keys,
|
||||||
IReadOnlyList<NodeAcl> grants);
|
IReadOnlyList<NodeAcl> grants,
|
||||||
|
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -204,7 +220,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
|
|||||||
string? clusterId,
|
string? clusterId,
|
||||||
string revisionHash,
|
string revisionHash,
|
||||||
IReadOnlyList<ApiKey> keys,
|
IReadOnlyList<ApiKey> keys,
|
||||||
IReadOnlyList<NodeAcl> grants)
|
IReadOnlyList<NodeAcl> grants,
|
||||||
|
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(revisionHash);
|
ArgumentNullException.ThrowIfNull(revisionHash);
|
||||||
|
|
||||||
@@ -227,7 +244,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
|
|||||||
? previous.GenerationId
|
? previous.GenerationId
|
||||||
: previous.GenerationId + 1;
|
: previous.GenerationId + 1;
|
||||||
|
|
||||||
var next = DataPlaneAuthoritySnapshot.Create(clusterId, revisionHash, generation, keys, grants);
|
var next = DataPlaneAuthoritySnapshot.Create(
|
||||||
|
clusterId, revisionHash, generation, keys, grants, scopePaths);
|
||||||
Volatile.Write(ref _current, next);
|
Volatile.Write(ref _current, next);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,146 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Address of a node in the 6-level scope hierarchy the Phase 6.2 evaluator walks.
|
/// Address of a node in the ACL scope hierarchy the evaluator walks: a cluster, a realm, and the
|
||||||
/// Assembled by the dispatch layer from the node's namespace + UNS path + tag; passed
|
/// node's path within that realm. Assembled by the dispatch layer from the node's NodeId; passed to
|
||||||
/// to <see cref="IPermissionEvaluator"/> which walks the matching trie path.
|
/// <see cref="IPermissionEvaluator"/>, which walks the matching trie path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>Per the Phase 6.2 Stream B plan the hierarchy is
|
/// <para>
|
||||||
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> for all
|
/// The hierarchy is <c>Cluster → Realm → …node path…</c>. The node path is <b>variable length</b>,
|
||||||
/// (Equipment-kind) namespaces. Galaxy is a standard Equipment-kind driver, so Galaxy
|
/// which is the whole reason this type is no longer a fixed set of level properties (#520 stage 3):
|
||||||
/// points are ordinary equipment tags that resolve through this same walk.</para>
|
/// </para>
|
||||||
///
|
/// <list type="bullet">
|
||||||
/// <para>Unset mid-path levels (e.g. a Cluster-scoped request with no UnsArea) leave
|
/// <item>
|
||||||
/// the corresponding id <c>null</c>. The evaluator walks as far as the scope goes +
|
/// <b>UNS</b> paths are always <c>Area / Line / Equipment [/ EffectiveName]</c> — fixed depth,
|
||||||
/// stops at the first null.</para>
|
/// because equipment is a tree leaf.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>Raw</b> paths are <c>folder… / Driver / Device / group… / Tag</c>, and BOTH the folder
|
||||||
|
/// chain and the tag-group chain nest arbitrarily deep (<c>RawFolder.ParentRawFolderId</c>,
|
||||||
|
/// <c>TagGroup.ParentTagGroupId</c>). There is no schema depth cap — the 256 in
|
||||||
|
/// <c>RawPathResolver</c> is a corrupt-cycle guard, not a limit. A fixed-arity scope record
|
||||||
|
/// therefore cannot address a real raw tree at all; the previous six-property shape silently
|
||||||
|
/// capped it at five levels and mislabelled every one of them.
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// The realm segment is NOT carried here. It is derived from <see cref="Kind"/> by
|
||||||
|
/// <see cref="NodeAclRealms.SegmentFor(NodeHierarchyKind)"/>, which the scope-path producer also uses — so the two
|
||||||
|
/// sides cannot disagree about its spelling. That matters more than it looks: the realm is a hard
|
||||||
|
/// gate at level 1 of the walk, so a mismatch would make every sub-cluster grant in the system
|
||||||
|
/// unreachable while cluster-scoped grants kept working, which reads as "ACLs work" right up until
|
||||||
|
/// someone tries to narrow one.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record NodeScope
|
public sealed record NodeScope
|
||||||
{
|
{
|
||||||
/// <summary>Cluster the node belongs to. Required.</summary>
|
/// <summary>Cluster the node belongs to. Required.</summary>
|
||||||
public required string ClusterId { get; init; }
|
public required string ClusterId { get; init; }
|
||||||
|
|
||||||
/// <summary>Namespace within the cluster. Null is not allowed for a request against a real node.</summary>
|
/// <summary>Which realm — and therefore which hierarchy shape — the node lives in.</summary>
|
||||||
public string? NamespaceId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>UNS area (e.g. "warsaw-west") below the namespace.</summary>
|
|
||||||
public string? UnsAreaId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>UNS line below the area.</summary>
|
|
||||||
public string? UnsLineId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Equipment row below the line.</summary>
|
|
||||||
public string? EquipmentId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Target tag id when the scope addresses a specific tag; null for equipment-level scopes.</summary>
|
|
||||||
public string? TagId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Which hierarchy applies. Currently only Equipment-kind (UNS) exists.</summary>
|
|
||||||
public required NodeHierarchyKind Kind { get; init; }
|
public required NodeHierarchyKind Kind { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The node's path within its realm, root-first, excluding the realm segment itself. Empty
|
||||||
|
/// addresses the realm as a whole. Segment comparison is case-sensitive ordinal, matching
|
||||||
|
/// <c>RawPaths.Comparer</c> and OPC UA NodeId identity.
|
||||||
|
/// </summary>
|
||||||
|
public required IReadOnlyList<string> Segments { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Selector for the scope-hierarchy shape.</summary>
|
/// <summary>Selector for the scope-hierarchy shape — one member per OPC UA namespace (realm).</summary>
|
||||||
public enum NodeHierarchyKind
|
public enum NodeHierarchyKind
|
||||||
{
|
{
|
||||||
/// <summary><c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> — UNS / Equipment kind.</summary>
|
/// <summary><c>Area / Line / Equipment [/ EffectiveName]</c> — the UNS realm.</summary>
|
||||||
Equipment,
|
Uns,
|
||||||
|
|
||||||
|
/// <summary><c>folder… / Driver / Device / group… / Tag</c> — the raw device-tree realm. (Appended, #520 stage 3.)</summary>
|
||||||
|
Raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The single authority for the realm segment that sits at level 1 of every permission trie, directly
|
||||||
|
/// under the cluster root.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This exists because v3 <b>has no <c>Namespace</c> entity</b>. The snapshot was retired when the
|
||||||
|
/// address space went dual-namespace ("the two OPC UA namespaces are implicit"), which left the
|
||||||
|
/// trie's level-1 gate with no configured key source at all — the pre-stage-3 walker read a
|
||||||
|
/// <c>NamespaceId</c> that nothing in the system ever produced, so the level could only ever fail
|
||||||
|
/// to match. Deriving the segment from the realm restores the level as something real without
|
||||||
|
/// reintroducing an entity.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The short tokens, rather than the namespace URIs, are deliberate: they are what an operator
|
||||||
|
/// types into a <see cref="Configuration.Enums.NodeAclScopeKind.Namespace"/> row's <c>ScopeId</c>,
|
||||||
|
/// and they read clearly in a diagnostic. They cannot collide with a user-authored segment,
|
||||||
|
/// because only a realm token is ever placed at level 1 — real names start at level 2.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class NodeAclRealms
|
||||||
|
{
|
||||||
|
/// <summary>Realm segment for the raw device tree (<c>https://zb.com/otopcua/raw</c>).</summary>
|
||||||
|
public const string Raw = "raw";
|
||||||
|
|
||||||
|
/// <summary>Realm segment for the UNS tree (<c>https://zb.com/otopcua/uns</c>).</summary>
|
||||||
|
public const string Uns = "uns";
|
||||||
|
|
||||||
|
/// <summary>The realm segment for a hierarchy kind.</summary>
|
||||||
|
/// <param name="kind">The hierarchy kind.</param>
|
||||||
|
/// <returns>The level-1 trie segment.</returns>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">The kind is not a defined member.</exception>
|
||||||
|
public static string SegmentFor(NodeHierarchyKind kind) => kind switch
|
||||||
|
{
|
||||||
|
NodeHierarchyKind.Uns => Uns,
|
||||||
|
NodeHierarchyKind.Raw => Raw,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unhandled hierarchy kind."),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The realm segment for an address-space realm.</summary>
|
||||||
|
/// <param name="realm">The address-space realm carried at the OPC UA sinks.</param>
|
||||||
|
/// <returns>The level-1 trie segment.</returns>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">The realm is not a defined member.</exception>
|
||||||
|
public static string SegmentFor(AddressSpaceRealm realm) => realm switch
|
||||||
|
{
|
||||||
|
AddressSpaceRealm.Uns => Uns,
|
||||||
|
AddressSpaceRealm.Raw => Raw,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unhandled address-space realm."),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="segment"/> is exactly a canonical realm segment. Used by the trie
|
||||||
|
/// builder to reject a scope path that does not start at a realm — such a path addresses a node
|
||||||
|
/// the walk cannot reach, so the grant would look planted and never match.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="segment">The candidate segment.</param>
|
||||||
|
/// <returns><see langword="true"/> when it is a realm segment.</returns>
|
||||||
|
public static bool IsRealmSegment(string? segment) =>
|
||||||
|
string.Equals(segment, Raw, StringComparison.Ordinal)
|
||||||
|
|| string.Equals(segment, Uns, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonicalizes an operator-supplied realm token from a <c>Namespace</c>-scoped row's
|
||||||
|
/// <c>ScopeId</c>. Accepts the short token or the full namespace URI, in any case.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scopeId">The authored scope id.</param>
|
||||||
|
/// <returns>The canonical realm segment, or <see langword="null"/> when it names no realm.</returns>
|
||||||
|
public static string? TryCanonicalize(string? scopeId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(scopeId)) return null;
|
||||||
|
var trimmed = scopeId.Trim();
|
||||||
|
|
||||||
|
if (string.Equals(trimmed, Raw, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(trimmed, V3NodeIds.RawNamespaceUri, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Raw;
|
||||||
|
|
||||||
|
if (string.Equals(trimmed, Uns, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(trimmed, V3NodeIds.UnsNamespaceUri, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Uns;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,16 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-memory permission trie for one <c>(ClusterId, GenerationId)</c>. Walk from the cluster
|
/// In-memory permission trie for one <c>(ClusterId, GenerationId)</c>. Walk from the cluster root
|
||||||
/// root down through namespace → UNS levels → tag, OR-ing the
|
/// down through the realm and then each segment of the node's path, OR-ing the
|
||||||
/// <see cref="TrieGrant.PermissionFlags"/> granted at each visited level for each of the session's
|
/// <see cref="TrieGrant.PermissionFlags"/> granted at each visited level for each of the session's
|
||||||
/// principals. The accumulated bitmask is compared to the permission required by the
|
/// principals. The accumulated bitmask is compared to the permission required by the requested
|
||||||
/// requested <see cref="Abstractions.OpcUaOperation"/>.
|
/// <see cref="Abstractions.OpcUaOperation"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The walk is pure union (additive grants; no explicit Deny in v2.0):
|
/// The walk is pure union (additive grants; no explicit Deny in v2.0): encountering a grant at any
|
||||||
/// encountering a grant at any level contributes its flags, never revokes them. A grant at
|
/// level contributes its flags, never revokes them. A grant at the cluster root therefore cascades to
|
||||||
/// the Cluster root therefore cascades to every tag below it; a grant at a deep equipment
|
/// every node below it; a grant at a deep leaf is visible only on that subtree.
|
||||||
/// leaf is visible only on that equipment subtree.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class PermissionTrie
|
public sealed class PermissionTrie
|
||||||
{
|
{
|
||||||
@@ -27,9 +26,9 @@ public sealed class PermissionTrie
|
|||||||
public PermissionTrieNode Root { get; init; } = new();
|
public PermissionTrieNode Root { get; init; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Walk the trie collecting grants that apply to <paramref name="scope"/> for any of the
|
/// Walk the trie collecting grants that apply to <paramref name="scope"/> for any of the session's
|
||||||
/// session's <paramref name="principals"/>. Returns the matched-grant list; the caller
|
/// <paramref name="principals"/>. Returns the matched-grant list; the caller OR-s the flag bits to
|
||||||
/// OR-s the flag bits to decide whether the requested permission is carried.
|
/// decide whether the requested permission is carried.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="scope">The node scope to match permissions for.</param>
|
/// <param name="scope">The node scope to match permissions for.</param>
|
||||||
/// <param name="principals">The principal names carried by the session (API-key principals, or the reserved "anonymous").</param>
|
/// <param name="principals">The principal names carried by the session (API-key principals, or the reserved "anonymous").</param>
|
||||||
@@ -44,47 +43,42 @@ public sealed class PermissionTrie
|
|||||||
|
|
||||||
var matches = new List<MatchedGrant>();
|
var matches = new List<MatchedGrant>();
|
||||||
|
|
||||||
// Level 0 — cluster-scoped grants.
|
// Level 0 — cluster-scoped grants apply to every node in the cluster.
|
||||||
CollectAtLevel(Root, NodeAclScopeKind.Cluster, groups, matches);
|
CollectAtLevel(Root, groups, matches);
|
||||||
|
|
||||||
// Level 1 — namespace.
|
// Level 1 — the realm. Derived from the scope's kind rather than carried on it, so this can
|
||||||
if (scope.NamespaceId is null) return matches;
|
// never disagree with the spelling the scope-path producer used. SegmentFor throws on an
|
||||||
if (!Root.Children.TryGetValue(scope.NamespaceId, out var ns)) return matches;
|
// undefined kind, which is deliberate: a new realm that nobody taught this walk about must fail
|
||||||
CollectAtLevel(ns, NodeAclScopeKind.Namespace, groups, matches);
|
// loudly rather than walk some other realm's subtree and return a confidently wrong verdict.
|
||||||
|
var realm = NodeAclRealms.SegmentFor(scope.Kind);
|
||||||
|
if (!Root.Children.TryGetValue(realm, out var node)) return matches;
|
||||||
|
CollectAtLevel(node, groups, matches);
|
||||||
|
|
||||||
// Below the namespace every kind walks the Equipment hierarchy — Galaxy is a standard
|
// Levels 2..n — the node's path within the realm, however deep it happens to be. There is no
|
||||||
// Equipment-kind driver, so Galaxy points are ordinary equipment tags keyed by
|
// per-kind ladder here on purpose: the UNS path (Area/Line/Equipment) and a raw path
|
||||||
// EquipmentId/path and resolve through this same walk.
|
// (folder…/Driver/Device/group…/Tag) are both just ordered segments, and raw nesting is
|
||||||
WalkEquipment(ns, scope, groups, matches);
|
// unbounded, so any fixed ladder would silently truncate deep raw trees.
|
||||||
|
foreach (var segment in scope.Segments)
|
||||||
|
{
|
||||||
|
if (segment is null || !node.Children.TryGetValue(segment, out var next)) return matches;
|
||||||
|
node = next;
|
||||||
|
CollectAtLevel(node, groups, matches);
|
||||||
|
}
|
||||||
|
|
||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WalkEquipment(PermissionTrieNode ns, NodeScope scope, HashSet<string> groups, List<MatchedGrant> matches)
|
private static void CollectAtLevel(PermissionTrieNode node, HashSet<string> groups, List<MatchedGrant> matches)
|
||||||
{
|
|
||||||
if (scope.UnsAreaId is null) return;
|
|
||||||
if (!ns.Children.TryGetValue(scope.UnsAreaId, out var area)) return;
|
|
||||||
CollectAtLevel(area, NodeAclScopeKind.UnsArea, groups, matches);
|
|
||||||
|
|
||||||
if (scope.UnsLineId is null) return;
|
|
||||||
if (!area.Children.TryGetValue(scope.UnsLineId, out var line)) return;
|
|
||||||
CollectAtLevel(line, NodeAclScopeKind.UnsLine, groups, matches);
|
|
||||||
|
|
||||||
if (scope.EquipmentId is null) return;
|
|
||||||
if (!line.Children.TryGetValue(scope.EquipmentId, out var eq)) return;
|
|
||||||
CollectAtLevel(eq, NodeAclScopeKind.Equipment, groups, matches);
|
|
||||||
|
|
||||||
if (scope.TagId is null) return;
|
|
||||||
if (!eq.Children.TryGetValue(scope.TagId, out var tag)) return;
|
|
||||||
CollectAtLevel(tag, NodeAclScopeKind.Tag, groups, matches);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CollectAtLevel(PermissionTrieNode node, NodeAclScopeKind level, HashSet<string> groups, List<MatchedGrant> matches)
|
|
||||||
{
|
{
|
||||||
foreach (var grant in node.Grants)
|
foreach (var grant in node.Grants)
|
||||||
{
|
{
|
||||||
|
// Provenance is the granting ROW's own scope kind, recorded when the trie was built — not the
|
||||||
|
// structural depth the grant happens to sit at. Depth-derived provenance was wrong in two
|
||||||
|
// directions: it could not name a raw level at all (the depth of "Device" varies with folder
|
||||||
|
// nesting), and where a producer emitted a path whose length disagreed with the row's kind it
|
||||||
|
// reported a level the operator never authored.
|
||||||
if (groups.Contains(grant.Principal))
|
if (groups.Contains(grant.Principal))
|
||||||
matches.Add(new MatchedGrant(grant.Principal, level, grant.PermissionFlags));
|
matches.Add(new MatchedGrant(grant.Principal, grant.ScopeKind, grant.PermissionFlags));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,12 +90,21 @@ public sealed class PermissionTrieNode
|
|||||||
public List<TrieGrant> Grants { get; } = [];
|
public List<TrieGrant> Grants { get; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Children keyed by the next level's id — namespace id under cluster; UnsAreaId or
|
/// Children keyed by the next level's segment — the realm token under the root, then entity names.
|
||||||
/// folder-segment name under namespace; etc. Comparer is OrdinalIgnoreCase so the walk
|
|
||||||
/// tolerates case drift between the NodeAcl row and the requested scope.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, PermissionTrieNode> Children { get; } = new(StringComparer.OrdinalIgnoreCase);
|
/// <remarks>
|
||||||
|
/// ⚠️ The comparer is <b>case-sensitive ordinal</b>, matching <c>RawPaths.Comparer</c> and OPC UA
|
||||||
|
/// NodeId identity. It was <c>OrdinalIgnoreCase</c> before #520 stage 3, which was a silent
|
||||||
|
/// widening: raw sibling names are unique under an ordinal comparer, so <c>Speed</c> and
|
||||||
|
/// <c>speed</c> can both exist under one device, and a grant on either would have matched both.
|
||||||
|
/// Case-sensitive matching is also the fail-closed direction — a case-drifted ACL row now grants
|
||||||
|
/// nothing instead of granting something adjacent.
|
||||||
|
/// </remarks>
|
||||||
|
public Dictionary<string, PermissionTrieNode> Children { get; } = new(StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Projection of a <see cref="Configuration.Entities.NodeAcl"/> row into the trie.</summary>
|
/// <summary>Projection of a <see cref="Configuration.Entities.NodeAcl"/> row into the trie.</summary>
|
||||||
public sealed record TrieGrant(string Principal, NodePermissions PermissionFlags);
|
/// <param name="Principal">The principal the row grants to.</param>
|
||||||
|
/// <param name="ScopeKind">The row's authored scope kind, carried for decision provenance.</param>
|
||||||
|
/// <param name="PermissionFlags">The permission bits the row grants.</param>
|
||||||
|
public sealed record TrieGrant(string Principal, NodeAclScopeKind ScopeKind, NodePermissions PermissionFlags);
|
||||||
|
|||||||
@@ -4,107 +4,116 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a <see cref="PermissionTrie"/> from a set of <see cref="NodeAcl"/> rows anchored
|
/// Builds a <see cref="PermissionTrie"/> from a set of <see cref="NodeAcl"/> rows anchored in one
|
||||||
/// in one generation. The trie is keyed on the rows' scope hierarchy — rows with
|
/// generation. Rows with <see cref="NodeAclScopeKind.Cluster"/> land at the trie root; every other
|
||||||
/// <see cref="NodeAclScopeKind.Cluster"/> land at the trie root, rows with
|
/// row is placed at the trie node(s) its <c>ScopeId</c> resolves to.
|
||||||
/// <see cref="NodeAclScopeKind.Tag"/> land at a leaf, etc.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>Intended to be called by <see cref="PermissionTrieCache"/> once per published
|
/// <para>
|
||||||
/// generation; the resulting trie is immutable for the life of the cache entry. Idempotent —
|
/// Intended to be called once per published generation; the resulting trie is immutable for the
|
||||||
/// two builds from the same rows produce equal tries (grant lists may be in insertion order;
|
/// life of the cache entry. Idempotent — two builds from the same rows produce equal tries.
|
||||||
/// evaluators don't depend on order).</para>
|
/// </para>
|
||||||
///
|
/// <para>
|
||||||
/// <para>The builder deliberately does not know about the node-row metadata the trie path
|
/// ⚠️ <b>There is no longer a "deterministic test mode".</b> Before #520 stage 3 a null
|
||||||
/// will be walked with. The caller assembles <see cref="NodeScope"/> values from the live
|
/// <c>scopePaths</c> made sub-cluster rows fall back to a direct child of the trie
|
||||||
/// config (UnsArea parent of UnsLine, etc.); this class only honors the <c>ScopeId</c>
|
/// root keyed on <c>ScopeId</c>. That position is unreachable by construction — level 1 of the walk
|
||||||
/// each row carries.</para>
|
/// is the realm — so the fallback produced grants that looked placed and could never match, and
|
||||||
|
/// every test that used it proved nothing about production placement. A row that cannot be
|
||||||
|
/// resolved is now <b>dropped with a diagnostic</b>, which is the fail-closed direction and is
|
||||||
|
/// honest about what happened.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class PermissionTrieBuilder
|
public static class PermissionTrieBuilder
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build a trie for one cluster/generation from the supplied rows. The caller is
|
/// Build a trie for one cluster/generation from the supplied rows. The caller is responsible for
|
||||||
/// responsible for pre-filtering rows to the target generation + cluster.
|
/// pre-filtering rows to the target generation + cluster.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="clusterId">Cluster the trie is being built for; rows for other clusters are skipped.</param>
|
/// <param name="clusterId">Cluster the trie is being built for; rows for other clusters are skipped.</param>
|
||||||
/// <param name="generationId">Config-generation the rows belong to; stamped on the returned trie.</param>
|
/// <param name="generationId">Config-generation the rows belong to; stamped on the returned trie.</param>
|
||||||
/// <param name="rows">ACL rows for this cluster + generation.</param>
|
/// <param name="rows">ACL rows for this cluster + generation.</param>
|
||||||
/// <param name="scopePaths">
|
/// <param name="scopePaths">
|
||||||
/// Optional <c>ScopeId</c> → multi-level trie-path lookup. When supplied, sub-cluster rows
|
/// <c>(ScopeKind, ScopeId)</c> → the trie path(s) that scope resolves to, as produced by
|
||||||
/// descend to their structurally-correct trie node. When null, sub-cluster rows fall back
|
/// <see cref="AclScopePathResolver" />. A scope resolves to <b>several</b> paths when one entity is
|
||||||
/// to a direct child of the trie root keyed on <c>ScopeId</c> — deterministic-test mode.
|
/// reachable at several places in the address space — a raw tag projected into N equipments by
|
||||||
|
/// <c>UnsTagReference</c> rows has one raw path plus N UNS paths, and a grant on it must cover all
|
||||||
|
/// of them or it would be bypassable through the NodeId it missed.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="diagnostic">
|
/// <param name="diagnostic">
|
||||||
/// Optional callback invoked when a sub-cluster row's <c>ScopeId</c> cannot be located
|
/// Optional callback invoked for every row that could not be placed. Production callers should wire
|
||||||
/// in <paramref name="scopePaths"/>. Production callers should wire a logger here so
|
/// a logger here — a dropped grant is invisible otherwise.
|
||||||
/// orphaned grants surface — silently dropping them under the wrong trie level was a
|
|
||||||
/// production hazard. The callback fires only when <paramref name="scopePaths"/>
|
|
||||||
/// is non-null (a null lookup is the explicit deterministic-test fallback mode).
|
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>The built <see cref="PermissionTrie"/> for the cluster + generation.</returns>
|
/// <returns>The built <see cref="PermissionTrie"/> for the cluster + generation.</returns>
|
||||||
public static PermissionTrie Build(
|
public static PermissionTrie Build(
|
||||||
string clusterId,
|
string clusterId,
|
||||||
long generationId,
|
long generationId,
|
||||||
IReadOnlyList<NodeAcl> rows,
|
IReadOnlyList<NodeAcl> rows,
|
||||||
IReadOnlyDictionary<string, NodeAclPath>? scopePaths = null,
|
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||||
Action<PermissionTrieBuildDiagnostic>? diagnostic = null)
|
Action<PermissionTrieBuildDiagnostic>? diagnostic = null)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||||
ArgumentNullException.ThrowIfNull(rows);
|
ArgumentNullException.ThrowIfNull(rows);
|
||||||
|
ArgumentNullException.ThrowIfNull(scopePaths);
|
||||||
|
|
||||||
var trie = new PermissionTrie { ClusterId = clusterId, GenerationId = generationId };
|
var trie = new PermissionTrie { ClusterId = clusterId, GenerationId = generationId };
|
||||||
|
|
||||||
foreach (var row in rows)
|
foreach (var row in rows)
|
||||||
{
|
{
|
||||||
if (!string.Equals(row.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
|
if (!string.Equals(row.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
var grant = new TrieGrant(row.Principal, row.PermissionFlags);
|
|
||||||
|
|
||||||
var node = row.ScopeKind switch
|
var grant = new TrieGrant(row.Principal, row.ScopeKind, row.PermissionFlags);
|
||||||
|
|
||||||
|
if (row.ScopeKind == NodeAclScopeKind.Cluster)
|
||||||
{
|
{
|
||||||
NodeAclScopeKind.Cluster => trie.Root,
|
trie.Root.Grants.Add(grant);
|
||||||
_ => Descend(trie.Root, row, scopePaths, diagnostic),
|
continue;
|
||||||
};
|
}
|
||||||
|
|
||||||
if (node is not null)
|
// A sub-cluster row with no ScopeId names nothing. It used to vanish here with no signal at
|
||||||
|
// all — unlike an unresolvable ScopeId, which at least emitted a diagnostic. Both are
|
||||||
|
// authoring mistakes and both now say so.
|
||||||
|
if (string.IsNullOrEmpty(row.ScopeId))
|
||||||
|
{
|
||||||
|
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
|
||||||
|
row.NodeAclId, row.ScopeKind, ScopeId: null,
|
||||||
|
PermissionTrieBuildDiagnosticReason.MissingScopeId));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scopePaths.TryGetValue(new AclScopeRef(row.ScopeKind, row.ScopeId), out var paths)
|
||||||
|
|| paths.Count == 0)
|
||||||
|
{
|
||||||
|
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
|
||||||
|
row.NodeAclId, row.ScopeKind, row.ScopeId,
|
||||||
|
PermissionTrieBuildDiagnosticReason.UnresolvedScope));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
// Guard the producer's output contract. Level 1 of the walk is always a realm token, so a
|
||||||
|
// path that is empty or does not start with one addresses a node the walker can never
|
||||||
|
// reach — the grant would look planted and could never match. That is the same silent
|
||||||
|
// failure the deleted root-child fallback used to produce; without this check it simply
|
||||||
|
// moves into the resolver, where nothing else cross-checks it.
|
||||||
|
if (path.Segments.Count == 0 || !NodeAclRealms.IsRealmSegment(path.Segments[0]))
|
||||||
|
{
|
||||||
|
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
|
||||||
|
row.NodeAclId, row.ScopeKind, row.ScopeId,
|
||||||
|
PermissionTrieBuildDiagnosticReason.InvalidScopePath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var node = trie.Root;
|
||||||
|
foreach (var segment in path.Segments)
|
||||||
|
node = EnsureChild(node, segment);
|
||||||
node.Grants.Add(grant);
|
node.Grants.Add(grant);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return trie;
|
return trie;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PermissionTrieNode? Descend(
|
|
||||||
PermissionTrieNode root,
|
|
||||||
NodeAcl row,
|
|
||||||
IReadOnlyDictionary<string, NodeAclPath>? scopePaths,
|
|
||||||
Action<PermissionTrieBuildDiagnostic>? diagnostic)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(row.ScopeId)) return null;
|
|
||||||
|
|
||||||
// For sub-cluster scopes the caller supplies a path lookup so we know the containing
|
|
||||||
// namespace / UnsArea / UnsLine ids. Without a path lookup we fall back to putting the
|
|
||||||
// row directly under the root using its ScopeId — works for deterministic tests, not
|
|
||||||
// for production where the hierarchy must be honored. If a scopePaths lookup IS
|
|
||||||
// provided but is missing the row's ScopeId, surface a diagnostic so the caller can
|
|
||||||
// log the orphan instead of silently dropping the grant under an unreachable node.
|
|
||||||
if (scopePaths is null || !scopePaths.TryGetValue(row.ScopeId, out var path))
|
|
||||||
{
|
|
||||||
if (scopePaths is not null)
|
|
||||||
{
|
|
||||||
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
|
|
||||||
NodeAclId: row.NodeAclId,
|
|
||||||
ScopeKind: row.ScopeKind,
|
|
||||||
ScopeId: row.ScopeId,
|
|
||||||
Reason: PermissionTrieBuildDiagnosticReason.MissingScopePath));
|
|
||||||
}
|
|
||||||
return EnsureChild(root, row.ScopeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var node = root;
|
|
||||||
foreach (var segment in path.Segments)
|
|
||||||
node = EnsureChild(node, segment);
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PermissionTrieNode EnsureChild(PermissionTrieNode parent, string key)
|
private static PermissionTrieNode EnsureChild(PermissionTrieNode parent, string key)
|
||||||
{
|
{
|
||||||
if (!parent.Children.TryGetValue(key, out var child))
|
if (!parent.Children.TryGetValue(key, out var child))
|
||||||
@@ -117,37 +126,41 @@ public static class PermissionTrieBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ordered list of trie-path segments from root to the target node. Supplied to
|
/// Ordered list of trie-path segments from the cluster root to the target node, <b>including</b> the
|
||||||
/// <see cref="PermissionTrieBuilder.Build"/> so the builder knows where a
|
/// realm segment at index 0 (see <see cref="NodeAclRealms"/>).
|
||||||
/// <see cref="NodeAclScopeKind.UnsLine"/>-scoped row sits in the hierarchy.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Segments">
|
/// <param name="Segments">Realm token, then the entity names that lead to the scoped node.</param>
|
||||||
/// Namespace id, then UnsAreaId / UnsLineId / EquipmentId / TagId as applicable.
|
|
||||||
/// </param>
|
|
||||||
public sealed record NodeAclPath(IReadOnlyList<string> Segments);
|
public sealed record NodeAclPath(IReadOnlyList<string> Segments);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Diagnostic emitted by <see cref="PermissionTrieBuilder.Build"/> when a row could not be
|
/// Diagnostic emitted by <see cref="PermissionTrieBuilder.Build"/> for a row that could not be placed.
|
||||||
/// placed at its structurally-correct trie node. Production callers should log these so
|
/// Production callers should log these so dropped grants surface to an operator.
|
||||||
/// orphaned grants surface instead of being silently dropped under an unreachable node.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="NodeAclId">The offending row's logical id.</param>
|
/// <param name="NodeAclId">The offending row's logical id.</param>
|
||||||
/// <param name="ScopeKind">The row's <see cref="NodeAclScopeKind"/>.</param>
|
/// <param name="ScopeKind">The row's <see cref="NodeAclScopeKind"/>.</param>
|
||||||
/// <param name="ScopeId">The row's <c>ScopeId</c> that could not be located.</param>
|
/// <param name="ScopeId">The row's <c>ScopeId</c>, or <see langword="null"/> when it had none.</param>
|
||||||
/// <param name="Reason">Why the diagnostic fired.</param>
|
/// <param name="Reason">Why the diagnostic fired.</param>
|
||||||
public sealed record PermissionTrieBuildDiagnostic(
|
public sealed record PermissionTrieBuildDiagnostic(
|
||||||
string NodeAclId,
|
string NodeAclId,
|
||||||
NodeAclScopeKind ScopeKind,
|
NodeAclScopeKind ScopeKind,
|
||||||
string ScopeId,
|
string? ScopeId,
|
||||||
PermissionTrieBuildDiagnosticReason Reason);
|
PermissionTrieBuildDiagnosticReason Reason);
|
||||||
|
|
||||||
/// <summary>Reasons <see cref="PermissionTrieBuildDiagnostic"/> can be emitted.</summary>
|
/// <summary>Reasons <see cref="PermissionTrieBuildDiagnostic"/> can be emitted. The grant is dropped in every case.</summary>
|
||||||
public enum PermissionTrieBuildDiagnosticReason
|
public enum PermissionTrieBuildDiagnosticReason
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The row's <c>ScopeId</c> was not present in the supplied <c>scopePaths</c> lookup.
|
/// The row's <c>ScopeId</c> did not resolve to any node in the deployed address space — a dangling
|
||||||
/// The grant is placed as a direct child of the trie root keyed on <c>ScopeId</c> — a
|
/// id, a deleted entity, or a scope kind with no v3 meaning (<see cref="NodeAclScopeKind.FolderSegment"/>).
|
||||||
/// position the production trie walker cannot reach for multi-level scopes.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
MissingScopePath,
|
UnresolvedScope,
|
||||||
|
|
||||||
|
/// <summary>The row is narrower than <see cref="NodeAclScopeKind.Cluster"/> but carries no <c>ScopeId</c>.</summary>
|
||||||
|
MissingScopeId,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A resolved path was empty or did not begin with a realm segment, so it addresses a trie node
|
||||||
|
/// the walk cannot reach. This is a bug in the scope-path producer, not an authoring mistake.
|
||||||
|
/// </summary>
|
||||||
|
InvalidScopePath,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,15 +29,28 @@ else if (!IsNew && _existing is null)
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="alert alert-warning" role="alert">
|
<div class="alert alert-warning" role="alert">
|
||||||
<strong>This grant will not be enforced.</strong>
|
<strong>Only <span class="mono">Cluster</span> scope is enforced today.</strong>
|
||||||
It is saved and shipped in every deployment artifact, but the OPC UA server never evaluates
|
Saved with scope kind <span class="mono">Cluster</span>, this grant confers a server-wide role
|
||||||
ACL rows — the permission evaluator has no production call site. Nothing you set below will
|
on its principal: <span class="mono">WriteOperate</span> → writing any tag value;
|
||||||
change what a client can read, write, browse or acknowledge.
|
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
|
||||||
|
(acknowledge / confirm / shelve). No other permission bit is evaluated yet.
|
||||||
<br />
|
<br />
|
||||||
Real access control today is fleet-wide LDAP-group → role mapping
|
Saved with any narrower scope (<span class="mono">Namespace</span>,
|
||||||
(<span class="mono">Security:Ldap:GroupToRole</span>):
|
<span class="mono">UnsArea</span>, <span class="mono">UnsLine</span>,
|
||||||
<span class="mono">WriteOperate</span> to write any tag,
|
<span class="mono">Equipment</span>, <span class="mono">Tag</span>,
|
||||||
<span class="mono">AlarmAck</span> to acknowledge any alarm. Reads are ungated.
|
<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.
|
||||||
|
<br />
|
||||||
|
Read, Browse, Subscribe and HistoryRead are <strong>not</strong> gated for any admitted
|
||||||
|
session, including anonymous.
|
||||||
|
<br />
|
||||||
|
<strong>Principal</strong> is an API-key principal — or the reserved
|
||||||
|
<span class="mono">anonymous</span> principal — not an LDAP group: a client presents an OPC UA
|
||||||
|
UserName token with username <span class="mono">apikey</span> and password
|
||||||
|
<span class="mono">otua_<keyId>_<secret></span>. Mint keys on the
|
||||||
|
<span class="mono">/apikeys</span> page; LDAP now covers AdminUI login only.
|
||||||
See <span class="mono">docs/security.md</span>.
|
See <span class="mono">docs/security.md</span>.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,7 +67,7 @@ else
|
|||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label" for="grp">Principal</label>
|
<label class="form-label" for="grp">Principal</label>
|
||||||
<InputText id="grp" @bind-Value="_form.Principal" class="form-control form-control-sm mono"
|
<InputText id="grp" @bind-Value="_form.Principal" class="form-control form-control-sm mono"
|
||||||
placeholder="cn=Operators,ou=FleetAdmin,dc=zb,dc=local" />
|
placeholder="scadabridge-dcl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -66,14 +79,34 @@ else
|
|||||||
<option value="@NodeAclScopeKind.UnsArea">UnsArea</option>
|
<option value="@NodeAclScopeKind.UnsArea">UnsArea</option>
|
||||||
<option value="@NodeAclScopeKind.UnsLine">UnsLine</option>
|
<option value="@NodeAclScopeKind.UnsLine">UnsLine</option>
|
||||||
<option value="@NodeAclScopeKind.Equipment">Equipment</option>
|
<option value="@NodeAclScopeKind.Equipment">Equipment</option>
|
||||||
<option value="@NodeAclScopeKind.FolderSegment">FolderSegment</option>
|
<option value="@NodeAclScopeKind.FolderSegment">FolderSegment (retired — do not author)</option>
|
||||||
<option value="@NodeAclScopeKind.Tag">Tag</option>
|
<option value="@NodeAclScopeKind.Tag">Tag</option>
|
||||||
|
<option value="@NodeAclScopeKind.RawFolder">RawFolder</option>
|
||||||
|
<option value="@NodeAclScopeKind.RawDriver">RawDriver</option>
|
||||||
|
<option value="@NodeAclScopeKind.RawDevice">RawDevice</option>
|
||||||
|
<option value="@NodeAclScopeKind.RawTagGroup">RawTagGroup</option>
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label" for="target">Scope target ID</label>
|
<label class="form-label" for="target">Scope target ID</label>
|
||||||
<InputText id="target" @bind-Value="_form.ScopeId" class="form-control form-control-sm mono"
|
<InputText id="target" @bind-Value="_form.ScopeId" class="form-control form-control-sm mono"
|
||||||
placeholder="Leave blank for cluster-wide" />
|
placeholder="Leave blank for cluster-wide" />
|
||||||
|
<div class="form-text">
|
||||||
|
Free text, no picker — a typo saves a grant that resolves to nothing.
|
||||||
|
<span class="mono">Cluster</span> → leave blank ·
|
||||||
|
<span class="mono">Namespace</span> → <span class="mono">raw</span> or
|
||||||
|
<span class="mono">uns</span> ·
|
||||||
|
<span class="mono">UnsArea</span> → a UnsAreaId ·
|
||||||
|
<span class="mono">UnsLine</span> → a UnsLineId ·
|
||||||
|
<span class="mono">Equipment</span> → an EquipmentId ·
|
||||||
|
<span class="mono">Tag</span> → a TagId (a raw tag; the grant covers the raw
|
||||||
|
node and every UNS projection of it) ·
|
||||||
|
<span class="mono">RawFolder</span> → a RawFolderId ·
|
||||||
|
<span class="mono">RawDriver</span> → a DriverInstanceId ·
|
||||||
|
<span class="mono">RawDevice</span> → a DeviceId ·
|
||||||
|
<span class="mono">RawTagGroup</span> → a TagGroupId ·
|
||||||
|
<span class="mono">FolderSegment</span> → retired v2 kind, resolves nothing.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
@@ -20,29 +20,42 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="alert alert-warning" role="alert">
|
<div class="alert alert-warning" role="alert">
|
||||||
<strong>These rules are not enforced.</strong>
|
<strong>Only <span class="mono">Cluster</span>-scoped grants are enforced today.</strong>
|
||||||
ACL rows are saved and shipped in every deployment artifact, but the OPC UA server never
|
A Cluster-scoped row confers a server-wide role on its principal:
|
||||||
evaluates them — the permission evaluator has no production call site. Authoring a grant here
|
<span class="mono">WriteOperate</span> → writing any tag value;
|
||||||
changes nothing about what a client can read or write.
|
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
|
||||||
|
(acknowledge / confirm / shelve). No other permission bit is evaluated yet.
|
||||||
<br />
|
<br />
|
||||||
What <em>is</em> enforced today is coarse and fleet-wide: a client needs the
|
<strong>Narrower grants confer nothing.</strong>
|
||||||
<span class="mono">WriteOperate</span> role to write any tag and
|
<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
|
||||||
<span class="mono">AlarmAck</span> to acknowledge any alarm, both mapped from LDAP groups via
|
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
|
||||||
<span class="mono">Security:Ldap:GroupToRole</span>. Reads, browses, subscriptions and history
|
<span class="mono">Tag</span> and the <span class="mono">Raw*</span> kinds are saved and shipped
|
||||||
reads are <strong>not</strong> restricted at all. See <span class="mono">docs/security.md</span>.
|
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.
|
||||||
|
<br />
|
||||||
|
Read, Browse, Subscribe and HistoryRead are <strong>not</strong> gated for any admitted
|
||||||
|
session, including anonymous.
|
||||||
|
<br />
|
||||||
|
Identity is an <strong>API key</strong>, not LDAP: a client presents an OPC UA UserName token
|
||||||
|
with username <span class="mono">apikey</span> and password
|
||||||
|
<span class="mono">otua_<keyId>_<secret></span>, and the key resolves to the
|
||||||
|
principal named on these rows. Mint keys on the <span class="mono">/apikeys</span> page; LDAP
|
||||||
|
now covers AdminUI login only. See <span class="mono">docs/security.md</span>.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
<section class="panel notice rise" style="animation-delay:.02s">
|
||||||
ACL rows grant principals specific <span class="mono">NodePermissions</span> on a scope
|
ACL rows grant principals specific <span class="mono">NodePermissions</span> on a scope
|
||||||
(a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of
|
(the cluster, a namespace, an area, an equipment, a raw device, a tag). A principal is an API
|
||||||
fleet-wide LDAP-group → role mapping; ACLs here are the finer-grained per-node scope.
|
key's principal, or the reserved <span class="mono">anonymous</span> principal that an
|
||||||
|
anonymous session authenticates as.
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||||
<div class="panel-head">@_rows.Count ACL row@(_rows.Count == 1 ? "" : "s")</div>
|
<div class="panel-head">@_rows.Count ACL row@(_rows.Count == 1 ? "" : "s")</div>
|
||||||
@if (_rows.Count == 0)
|
@if (_rows.Count == 0)
|
||||||
{
|
{
|
||||||
<div style="padding:1rem" class="text-muted">No ACL rows for this cluster — default permissions from the fleet-wide LDAP group mapping apply.</div>
|
<div style="padding:1rem" class="text-muted">No ACL rows for this cluster — no principal holds <span class="mono">WriteOperate</span> or <span class="mono">AlarmAck</span> here, so writes and alarm acknowledges are refused. Reads, browses, subscriptions and history reads are unaffected.</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,10 +61,20 @@ public sealed class ArtifactApiKeyStore : IApiKeyStore
|
|||||||
Task.FromResult(ClusterGrants(_authority.Current, principal));
|
Task.FromResult(ClusterGrants(_authority.Current, principal));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Narrows a principal's grants to the Cluster-scoped ones, matching <c>ConfigDbApiKeyStore</c>.
|
/// Narrows a principal's grants to the Cluster-scoped ones. Only those confer an interim role
|
||||||
/// Only those confer an interim role (see <see cref="ApiKeyInterimRoleMapper"/>); stage 5 widens
|
/// (see <see cref="ApiKeyInterimRoleMapper"/>); stage 5 widens both stores to the full grant set
|
||||||
/// both stores to the full grant set once scope is actually enforced.
|
/// once scope is actually enforced.
|
||||||
/// </summary>
|
/// </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)
|
private static IReadOnlyList<NodeAcl> ClusterGrants(DataPlaneAuthoritySnapshot snapshot, string principal)
|
||||||
{
|
{
|
||||||
var all = snapshot.GrantsFor(principal);
|
var all = snapshot.GrantsFor(principal);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
@@ -16,6 +17,13 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|||||||
/// Reading per request is what makes revocation immediate here, which is why this store is
|
/// Reading per request is what makes revocation immediate here, which is why this store is
|
||||||
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node.
|
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>The ConfigDb holds the WHOLE fleet's ACL rows, not just this node's cluster's.</b> That is
|
||||||
|
/// the difference from <see cref="ArtifactApiKeyStore"/>, whose snapshot was already narrowed to
|
||||||
|
/// the node's own cluster by <c>DeploymentArtifact.ParseDataPlaneAuthority</c>. So this store must
|
||||||
|
/// confine grants to <see cref="ClusterId"/> itself — without that predicate a Cluster-scoped
|
||||||
|
/// grant authored in ANY cluster in the fleet conferred an interim server-wide role here.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class ConfigDbApiKeyStore : IApiKeyStore
|
public sealed class ConfigDbApiKeyStore : IApiKeyStore
|
||||||
{
|
{
|
||||||
@@ -23,7 +31,57 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
|
|||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="ConfigDbApiKeyStore"/> class.</summary>
|
/// <summary>Initializes a new instance of the <see cref="ConfigDbApiKeyStore"/> class.</summary>
|
||||||
/// <param name="factory">The config DB context factory.</param>
|
/// <param name="factory">The config DB context factory.</param>
|
||||||
public ConfigDbApiKeyStore(IDbContextFactory<OtOpcUaConfigDbContext> factory) => _factory = factory;
|
/// <param name="clusterId">
|
||||||
|
/// This node's own ClusterId, from <see cref="ResolveClusterId"/>. <see langword="null"/> on a
|
||||||
|
/// legacy / single-cluster node that carries no <c>cluster-{ClusterId}</c> role — see
|
||||||
|
/// <see cref="ClusterId"/> for what that means for filtering.
|
||||||
|
/// </param>
|
||||||
|
public ConfigDbApiKeyStore(IDbContextFactory<OtOpcUaConfigDbContext> factory, string? clusterId)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(factory);
|
||||||
|
_factory = factory;
|
||||||
|
ClusterId = string.IsNullOrWhiteSpace(clusterId) ? null : clusterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The node's own ClusterId, or <see langword="null"/> when it carries no cluster-scoped role.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A <see langword="null"/> ClusterId applies NO cluster filter, mirroring the artifact path's
|
||||||
|
/// <c>ClusterFilterMode.None</c> — a single-cluster artifact is applied whole. Failing closed here
|
||||||
|
/// instead would silently strip every grant on an existing single-cluster deployment, which is a
|
||||||
|
/// worse failure than the one being fixed and would not be visible until a client was denied.
|
||||||
|
/// </remarks>
|
||||||
|
public string? ClusterId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the node's ClusterId from its OWN configured roles, e.g. <c>cluster-SITE-A</c> →
|
||||||
|
/// <c>SITE-A</c>; <see langword="null"/> when no role is cluster-scoped.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the same derivation <see cref="Cluster.ClusterRoleInfo"/> performs for
|
||||||
|
/// <c>IClusterRoleInfo.ClusterId</c>, reusing the same <see cref="RoleParser"/> primitives —
|
||||||
|
/// first cluster-scoped role wins, as it does there.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>Takes <see cref="AkkaClusterOptions"/> (pure configuration), NOT
|
||||||
|
/// <c>IClusterRoleInfo</c> resolved from the service provider.</b> <c>ClusterRoleInfo</c>
|
||||||
|
/// depends on the <c>ActorSystem</c>; resolving it during host composition recurses into
|
||||||
|
/// ActorSystem construction and stack-overflows the node at boot while compiling clean (see
|
||||||
|
/// CLAUDE.md, per-cluster mesh Phase 6). The ClusterId is configuration, so read it from
|
||||||
|
/// configuration.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="options">The node's bound cluster options.</param>
|
||||||
|
/// <returns>The node's ClusterId, or <see langword="null"/> when it carries no cluster-scoped role.</returns>
|
||||||
|
public static string? ResolveClusterId(AkkaClusterOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
var clusterRole = options.Roles.FirstOrDefault(RoleParser.IsClusterRole);
|
||||||
|
return clusterRole is null ? null : RoleParser.ClusterIdFromRole(clusterRole);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
|
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
|
||||||
@@ -53,15 +111,33 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
|
|||||||
return await LoadClusterGrantsAsync(db, principal, ct).ConfigureAwait(false);
|
return await LoadClusterGrantsAsync(db, principal, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<NodeAcl>> LoadClusterGrantsAsync(
|
private async Task<IReadOnlyList<NodeAcl>> LoadClusterGrantsAsync(
|
||||||
OtOpcUaConfigDbContext db, string principal, CancellationToken ct)
|
OtOpcUaConfigDbContext db, string principal, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Only cluster-scoped rows can confer an interim role (see ApiKeyInterimRoleMapper), so only
|
// 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.
|
// those are fetched. Stage 5 widens this to the full grant set for per-node evaluation.
|
||||||
return await db.NodeAcls
|
var rows = await db.NodeAcls
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(a => a.Principal == principal && a.ScopeKind == NodeAclScopeKind.Cluster)
|
.Where(a => a.Principal == principal && a.ScopeKind == NodeAclScopeKind.Cluster)
|
||||||
.ToListAsync(ct)
|
.ToListAsync(ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (ClusterId is null) return rows;
|
||||||
|
|
||||||
|
// The ClusterId match is done HERE rather than as a SQL predicate, unlike the Enabled/expiry
|
||||||
|
// filters above, because it must be case-insensitive DETERMINISTICALLY. `a.ClusterId == ...` in
|
||||||
|
// SQL is decided by the database collation — case-insensitive on the usual SQL Server default,
|
||||||
|
// case-SENSITIVE on a CS collation or on the in-memory provider the tests use — and the two
|
||||||
|
// spellings genuinely differ: `Cluster:Roles` binds a role verbatim (cluster-SITE-A ⇒ SITE-A)
|
||||||
|
// while the OTOPCUA_ROLES path goes through RoleParser.Parse, which lower-cases it (⇒ site-a).
|
||||||
|
// A collation-dependent match would therefore strip every grant on a node configured the second
|
||||||
|
// way. OrdinalIgnoreCase is the SAME comparison DeploymentArtifact.ParseDataPlaneAuthority uses
|
||||||
|
// to scope the artifact's grants, so both stores confine identically.
|
||||||
|
var mine = new List<NodeAcl>(rows.Count);
|
||||||
|
foreach (var grant in rows)
|
||||||
|
{
|
||||||
|
if (string.Equals(grant.ClusterId, ClusterId, StringComparison.OrdinalIgnoreCase)) mine.Add(grant);
|
||||||
|
}
|
||||||
|
return mine;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,7 +376,15 @@ if (hasDriver)
|
|||||||
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
|
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
|
||||||
// on the very next request rather than on the next deployment. Preferred wherever it is available,
|
// on the very next request rather than on the next deployment. Preferred wherever it is available,
|
||||||
// including on a fused admin+driver node.
|
// including on a fused admin+driver node.
|
||||||
builder.Services.AddSingleton<IApiKeyStore, ConfigDbApiKeyStore>();
|
// The ClusterId confines the grants this node honours to its OWN cluster — the ConfigDb holds
|
||||||
|
// the whole fleet's ACL rows, unlike the artifact a driver-only node reads, which
|
||||||
|
// ParseDataPlaneAuthority already scoped. Taken from AkkaClusterOptions (pure configuration),
|
||||||
|
// NOT from IClusterRoleInfo: that depends on the ActorSystem, and the same options-not-services
|
||||||
|
// rule is what keeps the redundancy-singleton registration below from recursing into ActorSystem
|
||||||
|
// construction. Null on a node with no cluster-{ClusterId} role ⇒ no filter (legacy behaviour).
|
||||||
|
builder.Services.AddSingleton<IApiKeyStore>(sp => new ConfigDbApiKeyStore(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
||||||
|
ConfigDbApiKeyStore.ResolveClusterId(sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)));
|
||||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
||||||
|
|
||||||
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
|
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
@@ -46,13 +47,22 @@ public enum ClusterFilterMode
|
|||||||
/// lookup filters those, so that a revocation is visible rather than indistinguishable from a
|
/// lookup filters those, so that a revocation is visible rather than indistinguishable from a
|
||||||
/// missing key.</param>
|
/// missing key.</param>
|
||||||
/// <param name="Grants">The <c>NodeAcl</c> rows scoped to <paramref name="ClusterId"/>.</param>
|
/// <param name="Grants">The <c>NodeAcl</c> rows scoped to <paramref name="ClusterId"/>.</param>
|
||||||
|
/// <param name="ScopePaths">
|
||||||
|
/// Where each grant's scope sits in the address space, resolved from the artifact's own topology.
|
||||||
|
/// This is why the node needs no ConfigDb to build a permission trie: the artifact already carries
|
||||||
|
/// every raw and UNS parent link, so the paths are derivable from the same bytes the grants came in.
|
||||||
|
/// A scope that resolves to nothing is absent here, and the trie builder drops its grant with a
|
||||||
|
/// diagnostic rather than planting it somewhere unreachable.
|
||||||
|
/// </param>
|
||||||
public sealed record DataPlaneAuthorityProjection(
|
public sealed record DataPlaneAuthorityProjection(
|
||||||
string? ClusterId,
|
string? ClusterId,
|
||||||
IReadOnlyList<ApiKey> Keys,
|
IReadOnlyList<ApiKey> Keys,
|
||||||
IReadOnlyList<NodeAcl> Grants)
|
IReadOnlyList<NodeAcl> Grants,
|
||||||
|
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths)
|
||||||
{
|
{
|
||||||
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
|
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
|
||||||
public static readonly DataPlaneAuthorityProjection Empty = new(null, [], []);
|
public static readonly DataPlaneAuthorityProjection Empty =
|
||||||
|
new(null, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Resolved scoping decision for a node against an artifact.</summary>
|
/// <summary>Resolved scoping decision for a node against an artifact.</summary>
|
||||||
@@ -610,7 +620,8 @@ public static class DeploymentArtifact
|
|||||||
grants.Add(acl);
|
grants.Add(acl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DataPlaneAuthorityProjection(scope.ClusterId, keys, grants);
|
return new DataPlaneAuthorityProjection(
|
||||||
|
scope.ClusterId, keys, grants, BuildAclScopePaths(root, grants));
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
@@ -618,6 +629,109 @@ public static class DeploymentArtifact
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve every scope named by <paramref name="grants"/> against the artifact's own raw + UNS
|
||||||
|
/// topology, using the same <c>RawPathResolver</c> the address space is built through — so an ACL
|
||||||
|
/// path and the RawPath that forms a node's NodeId cannot drift apart.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> BuildAclScopePaths(
|
||||||
|
JsonElement root,
|
||||||
|
IReadOnlyList<NodeAcl> grants)
|
||||||
|
{
|
||||||
|
if (grants.Count == 0) return new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
||||||
|
|
||||||
|
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
|
||||||
|
foreach (var el in EnumerateArray(root, "RawFolders"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "RawFolderId");
|
||||||
|
if (id is null) continue;
|
||||||
|
folders[id] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var drivers = new Dictionary<string, (string? RawFolderId, string Name)>(StringComparer.Ordinal);
|
||||||
|
foreach (var el in EnumerateArray(root, "DriverInstances"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "DriverInstanceId");
|
||||||
|
if (id is null) continue;
|
||||||
|
drivers[id] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var devices = new Dictionary<string, (string DriverInstanceId, string Name)>(StringComparer.Ordinal);
|
||||||
|
foreach (var el in EnumerateArray(root, "Devices"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "DeviceId");
|
||||||
|
var driverId = ReadString(el, "DriverInstanceId");
|
||||||
|
if (id is null || driverId is null) continue;
|
||||||
|
devices[id] = (driverId, ReadString(el, "Name") ?? id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var groups = new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(StringComparer.Ordinal);
|
||||||
|
foreach (var el in EnumerateArray(root, "TagGroups"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "TagGroupId");
|
||||||
|
var deviceId = ReadString(el, "DeviceId");
|
||||||
|
if (id is null || deviceId is null) continue;
|
||||||
|
groups[id] = (ReadNullableString(el, "ParentTagGroupId"), deviceId, ReadString(el, "Name") ?? id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags = new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(StringComparer.Ordinal);
|
||||||
|
foreach (var el in EnumerateArray(root, "Tags"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "TagId");
|
||||||
|
var deviceId = ReadString(el, "DeviceId");
|
||||||
|
if (id is null || deviceId is null) continue;
|
||||||
|
tags[id] = (deviceId, ReadNullableString(el, "TagGroupId"), ReadString(el, "Name") ?? id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var areas = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var el in EnumerateArray(root, "UnsAreas"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "UnsAreaId");
|
||||||
|
if (id is null) continue;
|
||||||
|
areas[id] = ReadString(el, "Name") ?? id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = new Dictionary<string, (string UnsAreaId, string Name)>(StringComparer.Ordinal);
|
||||||
|
foreach (var el in EnumerateArray(root, "UnsLines"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "UnsLineId");
|
||||||
|
var areaId = ReadString(el, "UnsAreaId");
|
||||||
|
if (id is null || areaId is null) continue;
|
||||||
|
lines[id] = (areaId, ReadString(el, "Name") ?? id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var equipment = new Dictionary<string, (string UnsLineId, string Name)>(StringComparer.Ordinal);
|
||||||
|
foreach (var el in EnumerateArray(root, "Equipment"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "EquipmentId");
|
||||||
|
var lineId = ReadString(el, "UnsLineId");
|
||||||
|
if (id is null || lineId is null) continue;
|
||||||
|
equipment[id] = (lineId, ReadString(el, "Name") ?? id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var references = new List<(string EquipmentId, string TagId, string EffectiveName)>();
|
||||||
|
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
|
||||||
|
{
|
||||||
|
var equipmentId = ReadString(el, "EquipmentId");
|
||||||
|
var tagId = ReadString(el, "TagId");
|
||||||
|
if (equipmentId is null || tagId is null) continue;
|
||||||
|
|
||||||
|
// Effective name = the override when present, else the backing raw tag's own name. This is the
|
||||||
|
// same rule the address space materializes UNS variables under; a reference whose tag is
|
||||||
|
// missing from the artifact has no resolvable name and is skipped.
|
||||||
|
var effective = ReadNullableString(el, "DisplayNameOverride");
|
||||||
|
if (string.IsNullOrWhiteSpace(effective))
|
||||||
|
effective = tags.TryGetValue(tagId, out var t) ? t.Name : null;
|
||||||
|
if (effective is null) continue;
|
||||||
|
|
||||||
|
references.Add((equipmentId, tagId, effective));
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolver = new AclScopePathResolver(
|
||||||
|
folders, drivers, devices, groups, tags, areas, lines, equipment, references);
|
||||||
|
return resolver.Resolve(grants);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Reads one <c>ApiKeys</c> element; null when a field the credential check depends on is
|
/// <summary>Reads one <c>ApiKeys</c> element; null when a field the credential check depends on is
|
||||||
/// missing, so a half-written row can never authenticate.</summary>
|
/// missing, so a half-written row can never authenticate.</summary>
|
||||||
private static ApiKey? TryReadApiKey(JsonElement el)
|
private static ApiKey? TryReadApiKey(JsonElement el)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
@@ -533,12 +534,29 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
|||||||
// which is what lets a node recognise a re-applied revision without the hash riding the wire.
|
// which is what lets a node recognise a re-applied revision without the hash riding the wire.
|
||||||
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifact));
|
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifact));
|
||||||
var snapshot = _dataPlaneAuthority.Apply(
|
var snapshot = _dataPlaneAuthority.Apply(
|
||||||
projection.ClusterId, revision, projection.Keys, projection.Grants);
|
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(
|
_log.Info(
|
||||||
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants})",
|
"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],
|
snapshot.GenerationId, projection.ClusterId ?? "(single-cluster)", revision[..12],
|
||||||
projection.Keys.Count, projection.Grants.Count);
|
projection.Keys.Count, projection.Grants.Count, projection.ScopePaths.Count);
|
||||||
|
|
||||||
|
if (unresolvedScopes > 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
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.Configuration.Validation;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The #520 stage-3 deploy gate for <see cref="NodeAcl"/> scopes. An unresolvable scope is dropped by
|
||||||
|
/// the permission-trie builder and the deploy still seals green, so the row reads as a live grant in the
|
||||||
|
/// AdminUI while authorizing nothing — these pin that it fails the deploy instead.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class DraftValidatorNodeAclScopeTests
|
||||||
|
{
|
||||||
|
/// <summary>The cluster every ACL row under test is authored in.</summary>
|
||||||
|
private const string Own = "c";
|
||||||
|
|
||||||
|
/// <summary>A second, fully-populated cluster in the same snapshot — the cross-cluster counterparty.</summary>
|
||||||
|
private const string Other = "other";
|
||||||
|
|
||||||
|
private const string OwnEquipId = "EQ-000000000000";
|
||||||
|
private const string OtherEquipId = "EQ-0000000b0000";
|
||||||
|
|
||||||
|
private static NodeAcl Acl(NodeAclScopeKind kind, string? scopeId, string id = "acl-1") => new()
|
||||||
|
{
|
||||||
|
NodeAclId = id,
|
||||||
|
ClusterId = Own,
|
||||||
|
Principal = "svc-line3",
|
||||||
|
ScopeKind = kind,
|
||||||
|
ScopeId = scopeId,
|
||||||
|
PermissionFlags = NodePermissions.Read,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A draft carrying one of each scoped entity in <see cref="Own"/> and a mirrored set in
|
||||||
|
/// <see cref="Other"/>. Two clusters, not one, so that a cross-cluster id is genuinely present in the
|
||||||
|
/// snapshot: if the rule's lookups were cluster-filtered such an id would present as merely
|
||||||
|
/// unresolved and the containment check could never fire.
|
||||||
|
/// </summary>
|
||||||
|
private static DraftSnapshot Draft(params NodeAcl[] acls) => new()
|
||||||
|
{
|
||||||
|
GenerationId = 1,
|
||||||
|
ClusterId = Own,
|
||||||
|
NodeAcls = acls,
|
||||||
|
RawFolders =
|
||||||
|
[
|
||||||
|
new RawFolder { RawFolderId = "rf-1", ClusterId = Own, Name = "plant" },
|
||||||
|
new RawFolder { RawFolderId = "rf-2", ClusterId = Other, Name = "plant2" },
|
||||||
|
],
|
||||||
|
DriverInstances =
|
||||||
|
[
|
||||||
|
new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = "di-1", ClusterId = Own, Name = "modbus",
|
||||||
|
DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-1",
|
||||||
|
},
|
||||||
|
new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = "di-2", ClusterId = Other, Name = "modbus2",
|
||||||
|
DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new Device { DeviceId = "dev-1", DriverInstanceId = "di-1", Name = "dev1", DeviceConfig = "{}" },
|
||||||
|
new Device { DeviceId = "dev-2", DriverInstanceId = "di-2", Name = "dev2", DeviceConfig = "{}" },
|
||||||
|
],
|
||||||
|
TagGroups =
|
||||||
|
[
|
||||||
|
new TagGroup { TagGroupId = "tg-1", DeviceId = "dev-1", Name = "group1" },
|
||||||
|
new TagGroup { TagGroupId = "tg-2", DeviceId = "dev-2", Name = "group2" },
|
||||||
|
],
|
||||||
|
UnsAreas =
|
||||||
|
[
|
||||||
|
new UnsArea { UnsAreaId = "area-1", ClusterId = Own, Name = "north" },
|
||||||
|
new UnsArea { UnsAreaId = "area-2", ClusterId = Other, Name = "south" },
|
||||||
|
],
|
||||||
|
UnsLines =
|
||||||
|
[
|
||||||
|
new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line3" },
|
||||||
|
new UnsLine { UnsLineId = "line-2", UnsAreaId = "area-2", Name = "line4" },
|
||||||
|
],
|
||||||
|
Equipment =
|
||||||
|
[
|
||||||
|
new Equipment
|
||||||
|
{
|
||||||
|
EquipmentUuid = Guid.Parse("00000000-0000-0000-0000-0000000000aa"),
|
||||||
|
EquipmentId = OwnEquipId,
|
||||||
|
Name = "filler", UnsLineId = "line-1", MachineCode = "m",
|
||||||
|
},
|
||||||
|
new Equipment
|
||||||
|
{
|
||||||
|
EquipmentUuid = Guid.Parse("0000000b-0000-0000-0000-000000000000"),
|
||||||
|
EquipmentId = OtherEquipId,
|
||||||
|
Name = "packer", UnsLineId = "line-2", MachineCode = "m",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new Tag
|
||||||
|
{
|
||||||
|
TagId = "tag-1", DeviceId = "dev-1", Name = "speed",
|
||||||
|
DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||||
|
},
|
||||||
|
new Tag
|
||||||
|
{
|
||||||
|
TagId = "tag-2", DeviceId = "dev-2", Name = "speed2",
|
||||||
|
DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The fixture itself must trip no rule, or a test asserting on the whole error list is really
|
||||||
|
/// asserting on unrelated noise. This also pins both hardcoded EquipmentIds against the canonical
|
||||||
|
/// derivation, which a later edit to the fixture's UUIDs would otherwise silently break.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void The_fixture_with_no_acl_rows_is_clean()
|
||||||
|
{
|
||||||
|
DraftValidator.Validate(Draft()).ShouldBeEmpty();
|
||||||
|
DraftValidator.DeriveEquipmentId(Guid.Parse("00000000-0000-0000-0000-0000000000aa")).ShouldBe(OwnEquipId);
|
||||||
|
DraftValidator.DeriveEquipmentId(Guid.Parse("0000000b-0000-0000-0000-000000000000")).ShouldBe(OtherEquipId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The row shape the trie builder drops without a word: a narrower kind with nothing to narrow to.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void A_non_Cluster_kind_without_a_ScopeId_is_a_deploy_error(string? scopeId)
|
||||||
|
{
|
||||||
|
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Equipment, scopeId)));
|
||||||
|
|
||||||
|
errors.ShouldContain(e => e.Code == "NodeAclScopeIdMissing" && e.Context == "acl-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Cluster grant ignores ScopeId, so a populated one means the author picked the wrong kind and the
|
||||||
|
/// grant is <em>broader</em> than intended — the direction that must never pass silently.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_Cluster_kind_carrying_a_ScopeId_is_a_deploy_error()
|
||||||
|
{
|
||||||
|
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Cluster, "EQ-000000000000")));
|
||||||
|
|
||||||
|
errors.ShouldContain(e => e.Code == "NodeAclScopeIdUnexpected" && e.Context == "acl-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_Cluster_kind_with_no_ScopeId_passes()
|
||||||
|
{
|
||||||
|
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Cluster, null)))
|
||||||
|
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One case per scoped kind: a well-formed id that names no such entity must fail. Kept exhaustive
|
||||||
|
/// because the kind→collection mapping is hand-written, and a kind wired to the wrong collection
|
||||||
|
/// would still "work" for every id that happens to exist in both.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(NodeAclScopeKind.UnsArea)]
|
||||||
|
[InlineData(NodeAclScopeKind.UnsLine)]
|
||||||
|
[InlineData(NodeAclScopeKind.Equipment)]
|
||||||
|
[InlineData(NodeAclScopeKind.Tag)]
|
||||||
|
[InlineData(NodeAclScopeKind.RawFolder)]
|
||||||
|
[InlineData(NodeAclScopeKind.RawDriver)]
|
||||||
|
[InlineData(NodeAclScopeKind.RawDevice)]
|
||||||
|
[InlineData(NodeAclScopeKind.RawTagGroup)]
|
||||||
|
public void A_ScopeId_naming_no_such_entity_is_a_deploy_error(NodeAclScopeKind kind)
|
||||||
|
{
|
||||||
|
var errors = DraftValidator.Validate(Draft(Acl(kind, "does-not-exist")));
|
||||||
|
|
||||||
|
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The counterpart to the theory above: each kind pointed at the id from its OWN collection must
|
||||||
|
/// pass. Without this the rule could reject everything and still look correct.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(NodeAclScopeKind.UnsArea, "area-1")]
|
||||||
|
[InlineData(NodeAclScopeKind.UnsLine, "line-1")]
|
||||||
|
[InlineData(NodeAclScopeKind.Equipment, OwnEquipId)]
|
||||||
|
[InlineData(NodeAclScopeKind.Tag, "tag-1")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawFolder, "rf-1")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawDriver, "di-1")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawDevice, "dev-1")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawTagGroup, "tg-1")]
|
||||||
|
public void A_ScopeId_naming_a_real_entity_of_its_own_kind_passes(NodeAclScopeKind kind, string scopeId)
|
||||||
|
{
|
||||||
|
DraftValidator.Validate(Draft(Acl(kind, scopeId)))
|
||||||
|
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The containment check, one case per kind. Each id is real and resolves to a real trie path — it
|
||||||
|
/// just belongs to another cluster, so the grant would confer authority across a cluster boundary.
|
||||||
|
/// Three of these kinds carry ClusterId directly; the other five are only reachable by walking a
|
||||||
|
/// parent chain, which is why every kind is covered rather than a representative one.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(NodeAclScopeKind.UnsArea, "area-2")]
|
||||||
|
[InlineData(NodeAclScopeKind.UnsLine, "line-2")]
|
||||||
|
[InlineData(NodeAclScopeKind.Equipment, OtherEquipId)]
|
||||||
|
[InlineData(NodeAclScopeKind.Tag, "tag-2")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawFolder, "rf-2")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawDriver, "di-2")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawDevice, "dev-2")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawTagGroup, "tg-2")]
|
||||||
|
public void A_ScopeId_naming_an_entity_in_another_cluster_is_a_deploy_error(
|
||||||
|
NodeAclScopeKind kind, string scopeId)
|
||||||
|
{
|
||||||
|
var errors = DraftValidator.Validate(Draft(Acl(kind, scopeId)));
|
||||||
|
|
||||||
|
errors.ShouldContain(e => e.Code == "NodeAclScopeCrossCluster" && e.Context == "acl-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The trap this check exists to avoid: if the lookups were cluster-filtered, a cross-cluster id
|
||||||
|
/// would present as unresolved, the operator would be told to fix a typo in an id that is not a typo,
|
||||||
|
/// and the containment rule would never fire at all. The two codes must be mutually exclusive.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(NodeAclScopeKind.UnsArea, "area-2")]
|
||||||
|
[InlineData(NodeAclScopeKind.UnsLine, "line-2")]
|
||||||
|
[InlineData(NodeAclScopeKind.Equipment, OtherEquipId)]
|
||||||
|
[InlineData(NodeAclScopeKind.Tag, "tag-2")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawFolder, "rf-2")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawDriver, "di-2")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawDevice, "dev-2")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawTagGroup, "tg-2")]
|
||||||
|
public void A_cross_cluster_scope_is_never_also_reported_as_unresolved(
|
||||||
|
NodeAclScopeKind kind, string scopeId)
|
||||||
|
{
|
||||||
|
var errors = DraftValidator.Validate(Draft(Acl(kind, scopeId)));
|
||||||
|
|
||||||
|
errors.ShouldNotContain(e => e.Code == "NodeAclScopeUnresolved");
|
||||||
|
errors.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)).ShouldHaveSingleItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The error must name both clusters — which one the grant is in and which one owns the
|
||||||
|
/// entity — because "re-author under the other cluster" and "repoint at the local entity" are different
|
||||||
|
/// fixes and the operator picks between them from this message alone.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void The_cross_cluster_message_names_both_clusters()
|
||||||
|
{
|
||||||
|
var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "dev-2")))
|
||||||
|
.First(e => e.Code == "NodeAclScopeCrossCluster");
|
||||||
|
|
||||||
|
error.Message.ShouldContain($"'{Own}'");
|
||||||
|
error.Message.ShouldContain($"'{Other}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An entity whose parent link dangles belongs to no cluster, so containment is unknowable — and it
|
||||||
|
/// resolves to no scope path either, which is the unresolved case. It must report that, once, rather
|
||||||
|
/// than a cross-cluster error built on a null owner.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_device_whose_driver_is_missing_reports_unresolved_only()
|
||||||
|
{
|
||||||
|
var draft = new DraftSnapshot
|
||||||
|
{
|
||||||
|
GenerationId = 1,
|
||||||
|
ClusterId = Own,
|
||||||
|
NodeAcls = [Acl(NodeAclScopeKind.RawDevice, "dev-orphan")],
|
||||||
|
Devices =
|
||||||
|
[
|
||||||
|
new Device
|
||||||
|
{
|
||||||
|
DeviceId = "dev-orphan", DriverInstanceId = "di-gone",
|
||||||
|
Name = "orphan", DeviceConfig = "{}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var errors = DraftValidator.Validate(draft);
|
||||||
|
|
||||||
|
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
|
||||||
|
errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The UNS counterpart, and a two-hop break rather than one: Equipment reaches its cluster
|
||||||
|
/// through UnsLine and only then UnsArea, so a missing line strands it just as a missing driver strands
|
||||||
|
/// a device.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void An_equipment_whose_line_is_missing_reports_unresolved_only()
|
||||||
|
{
|
||||||
|
var uuid = Guid.Parse("00000000-0000-0000-0000-0000000000aa");
|
||||||
|
var draft = new DraftSnapshot
|
||||||
|
{
|
||||||
|
GenerationId = 1,
|
||||||
|
ClusterId = Own,
|
||||||
|
NodeAcls = [Acl(NodeAclScopeKind.Equipment, OwnEquipId)],
|
||||||
|
Equipment =
|
||||||
|
[
|
||||||
|
new Equipment
|
||||||
|
{
|
||||||
|
EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid),
|
||||||
|
Name = "filler", UnsLineId = "line-gone", MachineCode = "m",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var errors = DraftValidator.Validate(draft);
|
||||||
|
|
||||||
|
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
|
||||||
|
errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScopeId is a bare string with no foreign key, so a real id of the <em>wrong</em> kind is exactly
|
||||||
|
/// the mistake that would otherwise hand a grant another entity's subtree.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_real_id_of_the_wrong_kind_is_still_unresolved()
|
||||||
|
{
|
||||||
|
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.UnsArea, "dev-1")));
|
||||||
|
|
||||||
|
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Both realm tokens and both full namespace URIs are accepted, in any case — matching
|
||||||
|
/// <c>NodeAclRealms.TryCanonicalize</c>, which is what actually resolves the row at build time.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("raw")]
|
||||||
|
[InlineData("uns")]
|
||||||
|
[InlineData("RAW")]
|
||||||
|
[InlineData("Uns")]
|
||||||
|
[InlineData(V3NodeIds.RawNamespaceUri)]
|
||||||
|
[InlineData(V3NodeIds.UnsNamespaceUri)]
|
||||||
|
[InlineData(" raw ")]
|
||||||
|
public void A_Namespace_scope_naming_a_realm_passes(string scopeId)
|
||||||
|
{
|
||||||
|
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Namespace, scopeId)))
|
||||||
|
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The server has exactly two namespaces; anything else names none of them.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ns=2")]
|
||||||
|
[InlineData("https://zb.com/otopcua/ns")] // the retired v2 single namespace
|
||||||
|
[InlineData("equipment")]
|
||||||
|
public void A_Namespace_scope_naming_no_realm_is_a_deploy_error(string scopeId)
|
||||||
|
{
|
||||||
|
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Namespace, scopeId)));
|
||||||
|
|
||||||
|
errors.ShouldContain(e => e.Code == "NodeAclNamespaceInvalid" && e.Context == "acl-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FolderSegment survives in the enum only so later ordinals do not shift; it has no v3 entity and
|
||||||
|
/// can never resolve, so the error has to name the replacement rather than just say "unresolved".
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void The_retired_FolderSegment_kind_is_a_deploy_error_naming_RawFolder()
|
||||||
|
{
|
||||||
|
var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.FolderSegment, "some-folder")))
|
||||||
|
.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal))
|
||||||
|
.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
error.Code.ShouldBe("NodeAclScopeKindRetired");
|
||||||
|
error.Context.ShouldBe("acl-1");
|
||||||
|
error.Message.ShouldContain("RawFolder");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A FolderSegment row with no ScopeId is both retired and missing an id. The retirement is the
|
||||||
|
/// actionable fact — the kind can never resolve whatever its ScopeId — so telling the operator to
|
||||||
|
/// supply one would send them to fix the wrong thing.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void FolderSegment_retirement_wins_over_the_missing_ScopeId_error(string? scopeId)
|
||||||
|
{
|
||||||
|
var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.FolderSegment, scopeId)))
|
||||||
|
.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal))
|
||||||
|
.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
error.Code.ShouldBe("NodeAclScopeKindRetired");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An ordinal outside the enum comes from a hand-edited row or a stale schema. Every other check
|
||||||
|
/// switches on the kind, so it must be rejected up front rather than falling into a default branch.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void An_undefined_ScopeKind_ordinal_is_a_deploy_error()
|
||||||
|
{
|
||||||
|
var errors = DraftValidator.Validate(Draft(Acl((NodeAclScopeKind)99, "anything")));
|
||||||
|
|
||||||
|
errors.ShouldContain(e => e.Code == "NodeAclScopeKindUndefined" && e.Context == "acl-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every bad row is reported in one pass — the validator's stated contract, and what lets an
|
||||||
|
/// operator fix a batch of ACLs in one edit instead of one deploy attempt each.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void All_offending_rows_are_reported_in_one_pass()
|
||||||
|
{
|
||||||
|
var errors = DraftValidator.Validate(Draft(
|
||||||
|
Acl(NodeAclScopeKind.Equipment, null, "acl-a"),
|
||||||
|
Acl(NodeAclScopeKind.Cluster, OwnEquipId, "acl-b"),
|
||||||
|
Acl(NodeAclScopeKind.RawDevice, "nope", "acl-c"),
|
||||||
|
Acl(NodeAclScopeKind.RawDevice, "dev-2", "acl-d")));
|
||||||
|
|
||||||
|
errors.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal))
|
||||||
|
.Select(e => e.Context)
|
||||||
|
.ShouldBe(["acl-a", "acl-b", "acl-c", "acl-d"], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coverage for <see cref="AclScopePathResolver"/> — the producer that turns an ACL row's
|
||||||
|
/// <c>ScopeId</c> into the trie path(s) a grant on it must be planted at (#520 stage 3).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The fixture is deliberately a <b>deeply nested</b> raw tree — two nested folders and two nested
|
||||||
|
/// tag groups — because the shape that broke the previous design is exactly the one a shallow
|
||||||
|
/// fixture cannot express. A five-level ladder resolves the shallowest possible raw tree correctly
|
||||||
|
/// and silently truncates this one.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AclScopePathResolverTests
|
||||||
|
{
|
||||||
|
// Plant/Line1 (nested folders) → ModbusDrv → dev1 → Fast/Inner (nested groups) → Speed
|
||||||
|
private static AclScopePathResolver Fixture() => new(
|
||||||
|
folders: new Dictionary<string, (string? ParentId, string Name)>
|
||||||
|
{
|
||||||
|
["RF-plant"] = (null, "Plant"),
|
||||||
|
["RF-line1"] = ("RF-plant", "Line1"),
|
||||||
|
},
|
||||||
|
drivers: new Dictionary<string, (string? RawFolderId, string Name)>
|
||||||
|
{
|
||||||
|
["DI-modbus"] = ("RF-line1", "ModbusDrv"),
|
||||||
|
["DI-rootlevel"] = (null, "RootDrv"),
|
||||||
|
},
|
||||||
|
devices: new Dictionary<string, (string DriverInstanceId, string Name)>
|
||||||
|
{
|
||||||
|
["DV-1"] = ("DI-modbus", "dev1"),
|
||||||
|
},
|
||||||
|
groups: new Dictionary<string, (string? ParentId, string DeviceId, string Name)>
|
||||||
|
{
|
||||||
|
["TG-fast"] = (null, "DV-1", "Fast"),
|
||||||
|
["TG-inner"] = ("TG-fast", "DV-1", "Inner"),
|
||||||
|
},
|
||||||
|
tags: new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>
|
||||||
|
{
|
||||||
|
["TAG-speed"] = ("DV-1", "TG-inner", "Speed"),
|
||||||
|
["TAG-bare"] = ("DV-1", null, "Bare"),
|
||||||
|
},
|
||||||
|
areas: new Dictionary<string, string> { ["UA-w"] = "West" },
|
||||||
|
lines: new Dictionary<string, (string UnsAreaId, string Name)> { ["UL-1"] = ("UA-w", "L1") },
|
||||||
|
equipment: new Dictionary<string, (string UnsLineId, string Name)>
|
||||||
|
{
|
||||||
|
["EQ-mixer"] = ("UL-1", "Mixer"),
|
||||||
|
["EQ-filler"] = ("UL-1", "Filler"),
|
||||||
|
},
|
||||||
|
references:
|
||||||
|
[
|
||||||
|
("EQ-mixer", "TAG-speed", "MixerSpeed"),
|
||||||
|
("EQ-filler", "TAG-speed", "FillerSpeed"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> Single(NodeAclScopeKind kind, string scopeId) =>
|
||||||
|
Fixture().ResolveOne(kind, scopeId).ShouldHaveSingleItem().Segments;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_nested_raw_folder_resolves_through_its_whole_ancestor_chain()
|
||||||
|
{
|
||||||
|
Single(NodeAclScopeKind.RawFolder, "RF-line1").ShouldBe(["raw", "Plant", "Line1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_raw_device_resolves_through_folders_and_its_driver()
|
||||||
|
{
|
||||||
|
Single(NodeAclScopeKind.RawDevice, "DV-1").ShouldBe(["raw", "Plant", "Line1", "ModbusDrv", "dev1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_nested_tag_group_resolves_through_its_own_ancestor_chain_too()
|
||||||
|
{
|
||||||
|
// Both nesting axes at once — the folder chain AND the group chain. This is the case a
|
||||||
|
// fixed-depth scope model cannot represent at all: it needs seven segments below the realm.
|
||||||
|
Single(NodeAclScopeKind.RawTagGroup, "TG-inner")
|
||||||
|
.ShouldBe(["raw", "Plant", "Line1", "ModbusDrv", "dev1", "Fast", "Inner"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_container_path_is_a_true_prefix_of_the_tag_beneath_it()
|
||||||
|
{
|
||||||
|
// The invariant the trie walk depends on: a grant on a container only cascades to the tags below
|
||||||
|
// it if the container's path is a genuine prefix of theirs. If the container paths were composed
|
||||||
|
// in a different order from the tag path — the obvious way to get this wrong, since the tag path
|
||||||
|
// comes from RawPathResolver and the container paths are composed here — a container grant would
|
||||||
|
// land on a sibling branch and silently protect nothing.
|
||||||
|
var resolver = Fixture();
|
||||||
|
var tag = resolver.ResolveOne(NodeAclScopeKind.Tag, "TAG-speed")
|
||||||
|
.First(p => p.Segments[0] == NodeAclRealms.Raw).Segments;
|
||||||
|
|
||||||
|
foreach (var (kind, id) in new[]
|
||||||
|
{
|
||||||
|
(NodeAclScopeKind.RawFolder, "RF-plant"),
|
||||||
|
(NodeAclScopeKind.RawFolder, "RF-line1"),
|
||||||
|
(NodeAclScopeKind.RawDriver, "DI-modbus"),
|
||||||
|
(NodeAclScopeKind.RawDevice, "DV-1"),
|
||||||
|
(NodeAclScopeKind.RawTagGroup, "TG-fast"),
|
||||||
|
(NodeAclScopeKind.RawTagGroup, "TG-inner"),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var container = resolver.ResolveOne(kind, id).ShouldHaveSingleItem().Segments;
|
||||||
|
container.Count.ShouldBeLessThan(tag.Count, $"{kind} {id}");
|
||||||
|
tag.Take(container.Count).ShouldBe(container, $"{kind} {id} is not a prefix of the tag path");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_tag_resolves_to_its_raw_node_AND_every_UNS_projection_of_it()
|
||||||
|
{
|
||||||
|
// A raw tag referenced by two equipments is reachable at three NodeIds, and a write through any
|
||||||
|
// of them lands on the same driver reference. A grant covering only the raw node would be
|
||||||
|
// bypassable through either UNS NodeId — so all three must be planted.
|
||||||
|
var paths = Fixture().ResolveOne(NodeAclScopeKind.Tag, "TAG-speed")
|
||||||
|
.Select(p => p.Segments)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
paths.Count.ShouldBe(3);
|
||||||
|
paths.ShouldContain(s => s.SequenceEqual(
|
||||||
|
new[] { "raw", "Plant", "Line1", "ModbusDrv", "dev1", "Fast", "Inner", "Speed" }));
|
||||||
|
paths.ShouldContain(s => s.SequenceEqual(new[] { "uns", "West", "L1", "Mixer", "MixerSpeed" }));
|
||||||
|
paths.ShouldContain(s => s.SequenceEqual(new[] { "uns", "West", "L1", "Filler", "FillerSpeed" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_tag_with_no_UNS_reference_resolves_to_its_raw_node_only()
|
||||||
|
{
|
||||||
|
// The positive control for the test above: it proves the three-path result came from the two
|
||||||
|
// references rather than from the resolver always emitting extra paths.
|
||||||
|
Single(NodeAclScopeKind.Tag, "TAG-bare")
|
||||||
|
.ShouldBe(["raw", "Plant", "Line1", "ModbusDrv", "dev1", "Bare"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_driver_at_the_cluster_root_has_no_folder_segments()
|
||||||
|
{
|
||||||
|
Single(NodeAclScopeKind.RawDriver, "DI-rootlevel").ShouldBe(["raw", "RootDrv"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(NodeAclScopeKind.UnsArea, "UA-w", new[] { "uns", "West" })]
|
||||||
|
[InlineData(NodeAclScopeKind.UnsLine, "UL-1", new[] { "uns", "West", "L1" })]
|
||||||
|
[InlineData(NodeAclScopeKind.Equipment, "EQ-mixer", new[] { "uns", "West", "L1", "Mixer" })]
|
||||||
|
public void UNS_scopes_resolve_to_their_name_path(NodeAclScopeKind kind, string id, string[] expected)
|
||||||
|
{
|
||||||
|
Single(kind, id).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("raw", "raw")]
|
||||||
|
[InlineData("RAW", "raw")]
|
||||||
|
[InlineData("uns", "uns")]
|
||||||
|
[InlineData("https://zb.com/otopcua/raw", "raw")]
|
||||||
|
[InlineData("https://zb.com/otopcua/uns", "uns")]
|
||||||
|
public void A_namespace_scope_accepts_the_token_or_the_full_uri(string scopeId, string expected)
|
||||||
|
{
|
||||||
|
Single(NodeAclScopeKind.Namespace, scopeId).ShouldBe([expected]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_namespace_scope_naming_no_realm_resolves_to_nothing()
|
||||||
|
{
|
||||||
|
Fixture().ResolveOne(NodeAclScopeKind.Namespace, "https://zb.com/otopcua/ns").ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(NodeAclScopeKind.RawFolder, "RF-nope")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawDriver, "DI-nope")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawDevice, "DV-nope")]
|
||||||
|
[InlineData(NodeAclScopeKind.RawTagGroup, "TG-nope")]
|
||||||
|
[InlineData(NodeAclScopeKind.Tag, "TAG-nope")]
|
||||||
|
[InlineData(NodeAclScopeKind.UnsArea, "UA-nope")]
|
||||||
|
[InlineData(NodeAclScopeKind.UnsLine, "UL-nope")]
|
||||||
|
[InlineData(NodeAclScopeKind.Equipment, "EQ-nope")]
|
||||||
|
public void A_dangling_scope_id_resolves_to_nothing(NodeAclScopeKind kind, string scopeId)
|
||||||
|
{
|
||||||
|
// Resolving to nothing is what makes the builder DROP the grant with a diagnostic. The dangerous
|
||||||
|
// alternative — inventing a path — would plant an operator's grant on some other node.
|
||||||
|
Fixture().ResolveOne(kind, scopeId).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_retired_FolderSegment_kind_resolves_to_nothing()
|
||||||
|
{
|
||||||
|
// It described a v2 hierarchy that v3 deleted. Deliberately unresolvable so a surviving row
|
||||||
|
// produces a diagnostic naming it, rather than a grant that quietly does nothing.
|
||||||
|
Fixture().ResolveOne(NodeAclScopeKind.FolderSegment, "RF-line1").ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_corrupt_parent_cycle_terminates_instead_of_hanging()
|
||||||
|
{
|
||||||
|
// Folder nesting is unbounded by design, so the walk cannot be bounded by a schema limit — only
|
||||||
|
// by a cycle guard. Without one a corrupt artifact would hang the deploy-apply thread rather than
|
||||||
|
// dropping one grant.
|
||||||
|
var resolver = new AclScopePathResolver(
|
||||||
|
folders: new Dictionary<string, (string? ParentId, string Name)>
|
||||||
|
{
|
||||||
|
["A"] = ("B", "A"),
|
||||||
|
["B"] = ("A", "B"),
|
||||||
|
},
|
||||||
|
drivers: new Dictionary<string, (string? RawFolderId, string Name)>(),
|
||||||
|
devices: new Dictionary<string, (string DriverInstanceId, string Name)>(),
|
||||||
|
groups: new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(),
|
||||||
|
tags: new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(),
|
||||||
|
areas: new Dictionary<string, string>(),
|
||||||
|
lines: new Dictionary<string, (string UnsAreaId, string Name)>(),
|
||||||
|
equipment: new Dictionary<string, (string UnsLineId, string Name)>(),
|
||||||
|
references: []);
|
||||||
|
|
||||||
|
resolver.ResolveOne(NodeAclScopeKind.RawFolder, "A").ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_keys_on_the_kind_as_well_as_the_id()
|
||||||
|
{
|
||||||
|
// ScopeId is a bare string with no FK and no type discriminator; ids are prefixed only by
|
||||||
|
// convention. Keying the lookup on the id alone would let one entity's id collide with another's
|
||||||
|
// and hand a grant the wrong node's paths — a silent wrong-node authorization.
|
||||||
|
var resolver = new AclScopePathResolver(
|
||||||
|
folders: new Dictionary<string, (string? ParentId, string Name)> { ["dup"] = (null, "AsFolder") },
|
||||||
|
drivers: new Dictionary<string, (string? RawFolderId, string Name)>(),
|
||||||
|
devices: new Dictionary<string, (string DriverInstanceId, string Name)>(),
|
||||||
|
groups: new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(),
|
||||||
|
tags: new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(),
|
||||||
|
areas: new Dictionary<string, string> { ["dup"] = "AsArea" },
|
||||||
|
lines: new Dictionary<string, (string UnsAreaId, string Name)>(),
|
||||||
|
equipment: new Dictionary<string, (string UnsLineId, string Name)>(),
|
||||||
|
references: []);
|
||||||
|
|
||||||
|
var resolved = resolver.Resolve(
|
||||||
|
[
|
||||||
|
Acl("acl-1", NodeAclScopeKind.RawFolder, "dup"),
|
||||||
|
Acl("acl-2", NodeAclScopeKind.UnsArea, "dup"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
resolved[new AclScopeRef(NodeAclScopeKind.RawFolder, "dup")]
|
||||||
|
.ShouldHaveSingleItem().Segments.ShouldBe(["raw", "AsFolder"]);
|
||||||
|
resolved[new AclScopeRef(NodeAclScopeKind.UnsArea, "dup")]
|
||||||
|
.ShouldHaveSingleItem().Segments.ShouldBe(["uns", "AsArea"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_omits_cluster_scoped_and_unresolvable_rows()
|
||||||
|
{
|
||||||
|
var resolved = Fixture().Resolve(
|
||||||
|
[
|
||||||
|
Acl("acl-cluster", NodeAclScopeKind.Cluster, null),
|
||||||
|
Acl("acl-dangling", NodeAclScopeKind.Equipment, "EQ-nope"),
|
||||||
|
Acl("acl-good", NodeAclScopeKind.Equipment, "EQ-mixer"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
resolved.Count.ShouldBe(1);
|
||||||
|
resolved.ShouldContainKey(new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NodeAcl Acl(string id, NodeAclScopeKind kind, string? scopeId) => new()
|
||||||
|
{
|
||||||
|
NodeAclId = id,
|
||||||
|
ClusterId = "c1",
|
||||||
|
Principal = "p",
|
||||||
|
ScopeKind = kind,
|
||||||
|
ScopeId = scopeId,
|
||||||
|
PermissionFlags = NodePermissions.Read,
|
||||||
|
};
|
||||||
|
}
|
||||||
+23
-15
@@ -14,6 +14,14 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
{
|
{
|
||||||
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
|
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// These tests are about key lookup and the generation counter, not about scope resolution, so
|
||||||
|
/// they install no scope paths. Resolution is covered by <c>AclScopePathResolverTests</c> and the
|
||||||
|
/// artifact-to-walk path by <c>DeploymentArtifactScopePathTests</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopes =
|
||||||
|
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Before_any_artifact_the_node_authenticates_nothing()
|
public void Before_any_artifact_the_node_authenticates_nothing()
|
||||||
{
|
{
|
||||||
@@ -30,8 +38,8 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
{
|
{
|
||||||
var sut = new DataPlaneAuthorityState();
|
var sut = new DataPlaneAuthorityState();
|
||||||
|
|
||||||
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
|
sut.Apply(null, "rev-1", [], [], NoScopes).GenerationId.ShouldBe(1);
|
||||||
sut.Apply(null, "rev-2", [], []).GenerationId.ShouldBe(2);
|
sut.Apply(null, "rev-2", [], [], NoScopes).GenerationId.ShouldBe(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -41,8 +49,8 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
// generation there would invalidate every cached authorization decision for no reason.
|
// generation there would invalidate every cached authorization decision for no reason.
|
||||||
var sut = new DataPlaneAuthorityState();
|
var sut = new DataPlaneAuthorityState();
|
||||||
|
|
||||||
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
|
sut.Apply(null, "rev-1", [], [], NoScopes).GenerationId.ShouldBe(1);
|
||||||
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
|
sut.Apply(null, "rev-1", [], [], NoScopes).GenerationId.ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -56,7 +64,7 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
var sut = new DataPlaneAuthorityState();
|
var sut = new DataPlaneAuthorityState();
|
||||||
|
|
||||||
var generations = new[] { "ffff", "cccc", "aaaa", "0001" }
|
var generations = new[] { "ffff", "cccc", "aaaa", "0001" }
|
||||||
.Select(rev => sut.Apply(null, rev, [], []).GenerationId)
|
.Select(rev => sut.Apply(null, rev, [], [], NoScopes).GenerationId)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
generations.ShouldBe([1, 2, 3, 4]);
|
generations.ShouldBe([1, 2, 3, 4]);
|
||||||
@@ -66,7 +74,7 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
public void A_usable_key_is_found_by_id()
|
public void A_usable_key_is_found_by_id()
|
||||||
{
|
{
|
||||||
var sut = new DataPlaneAuthorityState();
|
var sut = new DataPlaneAuthorityState();
|
||||||
sut.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
|
sut.Apply("cluster-a", "rev-1", [Key("abc", "p")], [], NoScopes);
|
||||||
|
|
||||||
sut.Current.FindUsableKey("abc", Now)!.Principal.ShouldBe("p");
|
sut.Current.FindUsableKey("abc", Now)!.Principal.ShouldBe("p");
|
||||||
}
|
}
|
||||||
@@ -77,7 +85,7 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
// Key ids are minted lowercase hex, but an operator copying one out of the AdminUI can upper-case
|
// Key ids are minted lowercase hex, but an operator copying one out of the AdminUI can upper-case
|
||||||
// it. A case-sensitive miss would read to them as "the key was revoked".
|
// it. A case-sensitive miss would read to them as "the key was revoked".
|
||||||
var sut = new DataPlaneAuthorityState();
|
var sut = new DataPlaneAuthorityState();
|
||||||
sut.Apply(null, "rev-1", [Key("abc", "p")], []);
|
sut.Apply(null, "rev-1", [Key("abc", "p")], [], NoScopes);
|
||||||
|
|
||||||
sut.Current.FindUsableKey("ABC", Now).ShouldNotBeNull();
|
sut.Current.FindUsableKey("ABC", Now).ShouldNotBeNull();
|
||||||
}
|
}
|
||||||
@@ -88,7 +96,7 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
var revoked = Key("abc", "p");
|
var revoked = Key("abc", "p");
|
||||||
revoked.Enabled = false;
|
revoked.Enabled = false;
|
||||||
var sut = new DataPlaneAuthorityState();
|
var sut = new DataPlaneAuthorityState();
|
||||||
sut.Apply(null, "rev-1", [revoked], []);
|
sut.Apply(null, "rev-1", [revoked], [], NoScopes);
|
||||||
|
|
||||||
sut.Current.FindUsableKey("abc", Now).ShouldBeNull();
|
sut.Current.FindUsableKey("abc", Now).ShouldBeNull();
|
||||||
}
|
}
|
||||||
@@ -101,7 +109,7 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
var expiring = Key("abc", "p");
|
var expiring = Key("abc", "p");
|
||||||
expiring.ExpiresUtc = Now;
|
expiring.ExpiresUtc = Now;
|
||||||
var sut = new DataPlaneAuthorityState();
|
var sut = new DataPlaneAuthorityState();
|
||||||
sut.Apply(null, "rev-1", [expiring], []);
|
sut.Apply(null, "rev-1", [expiring], [], NoScopes);
|
||||||
|
|
||||||
sut.Current.FindUsableKey("abc", Now).ShouldBeNull("expiry is inclusive — at the instant it expires it is gone");
|
sut.Current.FindUsableKey("abc", Now).ShouldBeNull("expiry is inclusive — at the instant it expires it is gone");
|
||||||
sut.Current.FindUsableKey("abc", Now.AddSeconds(-1)).ShouldNotBeNull();
|
sut.Current.FindUsableKey("abc", Now.AddSeconds(-1)).ShouldNotBeNull();
|
||||||
@@ -115,7 +123,7 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
[
|
[
|
||||||
Acl("acl-1", "alice", NodePermissions.Read),
|
Acl("acl-1", "alice", NodePermissions.Read),
|
||||||
Acl("acl-2", "bob", NodePermissions.WriteOperate),
|
Acl("acl-2", "bob", NodePermissions.WriteOperate),
|
||||||
]);
|
], NoScopes);
|
||||||
|
|
||||||
sut.Current.GrantsFor("alice").ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-1");
|
sut.Current.GrantsFor("alice").ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-1");
|
||||||
sut.Current.GrantsFor("nobody").ShouldBeEmpty();
|
sut.Current.GrantsFor("nobody").ShouldBeEmpty();
|
||||||
@@ -129,7 +137,7 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
[
|
[
|
||||||
Acl("acl-1", "alice", NodePermissions.Read),
|
Acl("acl-1", "alice", NodePermissions.Read),
|
||||||
Acl("acl-2", "alice", NodePermissions.WriteOperate),
|
Acl("acl-2", "alice", NodePermissions.WriteOperate),
|
||||||
]);
|
], NoScopes);
|
||||||
|
|
||||||
sut.Current.GrantsFor("alice").Count.ShouldBe(2);
|
sut.Current.GrantsFor("alice").Count.ShouldBe(2);
|
||||||
}
|
}
|
||||||
@@ -140,8 +148,8 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
// Merging would make revocation impossible: a key dropped from the artifact would live on in the
|
// Merging would make revocation impossible: a key dropped from the artifact would live on in the
|
||||||
// node's memory forever.
|
// node's memory forever.
|
||||||
var sut = new DataPlaneAuthorityState();
|
var sut = new DataPlaneAuthorityState();
|
||||||
sut.Apply(null, "rev-1", [Key("old", "p")], []);
|
sut.Apply(null, "rev-1", [Key("old", "p")], [], NoScopes);
|
||||||
sut.Apply(null, "rev-2", [Key("new", "p")], []);
|
sut.Apply(null, "rev-2", [Key("new", "p")], [], NoScopes);
|
||||||
|
|
||||||
sut.Current.FindUsableKey("old", Now).ShouldBeNull();
|
sut.Current.FindUsableKey("old", Now).ShouldBeNull();
|
||||||
sut.Current.FindUsableKey("new", Now).ShouldNotBeNull();
|
sut.Current.FindUsableKey("new", Now).ShouldNotBeNull();
|
||||||
@@ -154,7 +162,7 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
// artifact — where refusing to build the snapshot would leave the node authenticating nothing.
|
// artifact — where refusing to build the snapshot would leave the node authenticating nothing.
|
||||||
var sut = new DataPlaneAuthorityState();
|
var sut = new DataPlaneAuthorityState();
|
||||||
|
|
||||||
Should.NotThrow(() => sut.Apply(null, "rev-1", [Key("dup", "a"), Key("dup", "b")], []));
|
Should.NotThrow(() => sut.Apply(null, "rev-1", [Key("dup", "a"), Key("dup", "b")], [], NoScopes));
|
||||||
sut.Current.FindUsableKey("dup", Now).ShouldNotBeNull();
|
sut.Current.FindUsableKey("dup", Now).ShouldNotBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +172,7 @@ public sealed class DataPlaneAuthorityStateTests
|
|||||||
// The node manager's only source of cluster identity; NodeScope.ClusterId is required and the
|
// The node manager's only source of cluster identity; NodeScope.ClusterId is required and the
|
||||||
// evaluator fails closed when it disagrees with the session's.
|
// evaluator fails closed when it disagrees with the session's.
|
||||||
var sut = new DataPlaneAuthorityState();
|
var sut = new DataPlaneAuthorityState();
|
||||||
sut.Apply("cluster-a", "rev-1", [], []);
|
sut.Apply("cluster-a", "rev-1", [], [], NoScopes);
|
||||||
|
|
||||||
sut.Current.ClusterId.ShouldBe("cluster-a");
|
sut.Current.ClusterId.ShouldBe("cluster-a");
|
||||||
}
|
}
|
||||||
|
|||||||
+258
-64
@@ -7,17 +7,19 @@ using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Core-011 regression coverage for <see cref="PermissionTrieBuilder.Build"/>'s
|
/// Core-011 regression coverage for <see cref="PermissionTrieBuilder.Build"/>'s row placement:
|
||||||
/// <c>Descend</c> helper:
|
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item>With a <c>scopePaths</c> lookup the row must land at the correct multi-level
|
/// <item>A row whose scope resolves must land at the correct multi-level trie node — a deep
|
||||||
/// trie node — a deep <see cref="NodeAclScopeKind.UnsLine"/> grant must be visible
|
/// <see cref="NodeAclScopeKind.UnsLine"/> grant is visible ONLY when the requested scope
|
||||||
/// ONLY when the requested scope walks the same namespace/area/line chain.</item>
|
/// walks the same realm/area/line chain.</item>
|
||||||
/// <item>Without a <c>scopePaths</c> entry the row falls back to a direct child of
|
/// <item>A row whose scope does NOT resolve must be <b>dropped with a diagnostic</b>. Before
|
||||||
/// the namespace root keyed on the row's <c>ScopeId</c>. The builder must surface
|
/// #520 stage 3 it was planted as a direct child of the trie root keyed on <c>ScopeId</c>,
|
||||||
/// this fallback (warning callback) so callers know a grant was placed where the
|
/// a position level 1 of the walk (the realm) can never reach — so the grant looked placed
|
||||||
/// walker can't reach it for production hierarchies — silently dropping the grant
|
/// and could never match. Silently misplacing the grant is the Core-011 production
|
||||||
/// is the Core-011 production hazard.</item>
|
/// hazard.</item>
|
||||||
|
/// <item>A scope resolving to several paths (a raw tag projected into N equipments) must plant
|
||||||
|
/// the grant at <b>all</b> of them, or the grant is bypassable through the NodeId it
|
||||||
|
/// missed.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
@@ -35,78 +37,150 @@ public sealed class PermissionTrieBuilderTests
|
|||||||
PermissionFlags = flags,
|
PermissionFlags = flags,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static NodeScope EquipmentTag(string cluster, string ns, string area, string line, string equip, string tag) =>
|
/// <summary>One scope resolving to one path.</summary>
|
||||||
new()
|
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths(
|
||||||
|
params (NodeAclScopeKind Kind, string ScopeId, string[] Segments)[] entries) =>
|
||||||
|
entries.ToDictionary(
|
||||||
|
e => new AclScopeRef(e.Kind, e.ScopeId),
|
||||||
|
e => (IReadOnlyList<NodeAclPath>)new[] { new NodeAclPath(e.Segments) });
|
||||||
|
|
||||||
|
/// <summary>A UNS-realm trie path: <c>uns / …segments…</c>.</summary>
|
||||||
|
private static string[] UnsPath(params string[] segments) => [NodeAclRealms.Uns, .. segments];
|
||||||
|
|
||||||
|
/// <summary>A raw-realm trie path: <c>raw / …segments…</c>.</summary>
|
||||||
|
private static string[] RawPath(params string[] segments) => [NodeAclRealms.Raw, .. segments];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_Path_Not_Starting_At_A_Realm_Is_Rejected_Not_Planted()
|
||||||
|
{
|
||||||
|
// Level 1 of the walk is always a realm token, so each of these addresses a node CollectMatches
|
||||||
|
// can never reach. Without this guard the grant would be planted, look present in the trie, and
|
||||||
|
// silently never match — the same failure mode as the deleted root-child fallback, just relocated
|
||||||
|
// into the scope-path producer where nothing else cross-checks it.
|
||||||
|
string[][] malformed =
|
||||||
|
[
|
||||||
|
[], // empty path
|
||||||
|
["area-1", "line-1"], // producer forgot the realm segment
|
||||||
|
["Raw", "area-1"], // realm token with the wrong case
|
||||||
|
["https://zb.com/otopcua/raw", "area-1"], // URI where the short token belongs
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (var segments in malformed)
|
||||||
{
|
{
|
||||||
ClusterId = cluster,
|
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||||
NamespaceId = ns,
|
var rows = new[] { Row("cn=a", NodeAclScopeKind.UnsLine, "line-1", NodePermissions.Read) };
|
||||||
UnsAreaId = area,
|
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||||
UnsLineId = line,
|
{
|
||||||
EquipmentId = equip,
|
[new AclScopeRef(NodeAclScopeKind.UnsLine, "line-1")] = [new NodeAclPath(segments)],
|
||||||
TagId = tag,
|
};
|
||||||
Kind = NodeHierarchyKind.Equipment,
|
|
||||||
|
var label = segments.Length == 0 ? "(empty)" : string.Join('/', segments);
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
|
||||||
|
|
||||||
|
diagnostics.ShouldHaveSingleItem(label).Reason
|
||||||
|
.ShouldBe(PermissionTrieBuildDiagnosticReason.InvalidScopePath, label);
|
||||||
|
trie.Root.Children.ShouldBeEmpty(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_Plants_The_Valid_Paths_Of_A_Scope_Even_When_A_Sibling_Path_Is_Malformed()
|
||||||
|
{
|
||||||
|
// The positive control for the theory above: a guard that rejected every path would satisfy it.
|
||||||
|
// A multi-path scope must keep the reachable placements and drop only the broken one.
|
||||||
|
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||||
|
var rows = new[] { Row("cn=a", NodeAclScopeKind.Tag, "tag-1", NodePermissions.Read) };
|
||||||
|
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||||
|
{
|
||||||
|
[new AclScopeRef(NodeAclScopeKind.Tag, "tag-1")] =
|
||||||
|
[
|
||||||
|
new NodeAclPath(RawPath("Drv", "dev1", "Speed")),
|
||||||
|
new NodeAclPath(["area-1", "line-1"]),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
|
||||||
|
|
||||||
|
diagnostics.ShouldHaveSingleItem().Reason
|
||||||
|
.ShouldBe(PermissionTrieBuildDiagnosticReason.InvalidScopePath);
|
||||||
|
trie.CollectMatches(
|
||||||
|
new NodeScope
|
||||||
|
{
|
||||||
|
ClusterId = "c1",
|
||||||
|
Kind = NodeHierarchyKind.Raw,
|
||||||
|
Segments = ["Drv", "dev1", "Speed"],
|
||||||
|
},
|
||||||
|
["cn=a"]).ShouldHaveSingleItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A node in the UNS realm addressed by its path below the realm segment.</summary>
|
||||||
|
private static NodeScope UnsScope(string cluster, params string[] segments) =>
|
||||||
|
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Uns, Segments = segments };
|
||||||
|
|
||||||
|
/// <summary>A node in the raw realm addressed by its path below the realm segment.</summary>
|
||||||
|
private static NodeScope RawScope(string cluster, params string[] segments) =>
|
||||||
|
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Raw, Segments = segments };
|
||||||
|
|
||||||
/// <summary>Verifies that Build with ScopePaths places UnsLine row at correct multi-level node.</summary>
|
/// <summary>Verifies that Build with ScopePaths places UnsLine row at correct multi-level node.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_With_ScopePaths_Places_UnsLine_Row_At_Correct_Multi_Level_Node()
|
public void Build_With_ScopePaths_Places_UnsLine_Row_At_Correct_Multi_Level_Node()
|
||||||
{
|
{
|
||||||
// Scope path mirrors the production hierarchy: namespace → area → line.
|
// Scope path mirrors the production hierarchy: realm → area → line.
|
||||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
var paths = ScopePaths(
|
||||||
{
|
(NodeAclScopeKind.UnsLine, "line-42", UnsPath("area-1", "line-42")));
|
||||||
["line-42"] = new(new[] { "ns", "area-1", "line-42" }),
|
|
||||||
};
|
|
||||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
|
||||||
|
|
||||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
// Walk through the same chain — the grant must be reachable.
|
// Walk through the same chain — the grant must be reachable.
|
||||||
var matchOnLine = trie.CollectMatches(
|
var matchOnLine = trie.CollectMatches(
|
||||||
EquipmentTag("c1", "ns", "area-1", "line-42", "eq-A", "tag-A"),
|
UnsScope("c1", "area-1", "line-42", "eq-A", "tag-A"),
|
||||||
["cn=ops"]);
|
["cn=ops"]);
|
||||||
matchOnLine.Count.ShouldBe(1, "row must land at the correct multi-level trie node");
|
matchOnLine.Count.ShouldBe(1, "row must land at the correct multi-level trie node");
|
||||||
|
|
||||||
// A different line under the same area must not pick up the grant.
|
// A different line under the same area must not pick up the grant.
|
||||||
var matchOnOtherLine = trie.CollectMatches(
|
var matchOnOtherLine = trie.CollectMatches(
|
||||||
EquipmentTag("c1", "ns", "area-1", "line-99", "eq-A", "tag-A"),
|
UnsScope("c1", "area-1", "line-99", "eq-A", "tag-A"),
|
||||||
["cn=ops"]);
|
["cn=ops"]);
|
||||||
matchOnOtherLine.ShouldBeEmpty(
|
matchOnOtherLine.ShouldBeEmpty(
|
||||||
"grant anchored at line-42 must not leak to sibling line-99 under the same area");
|
"grant anchored at line-42 must not leak to sibling line-99 under the same area");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Build without ScopePaths falls back to root child for deterministic tests.</summary>
|
/// <summary>
|
||||||
|
/// Replaces <c>Build_Without_ScopePaths_Falls_Back_To_Root_Child_For_Tests</c>: the null-scopePaths
|
||||||
|
/// "deterministic test mode" is deleted. An unresolvable sub-cluster row is now dropped entirely —
|
||||||
|
/// it must NOT appear as a root child keyed on its <c>ScopeId</c>, because level 1 of the walk is
|
||||||
|
/// the realm and nothing can ever reach such a node.
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_Without_ScopePaths_Falls_Back_To_Root_Child_For_Tests()
|
public void Build_Unresolvable_Row_Is_Dropped_Not_Planted_At_Root_Child()
|
||||||
{
|
{
|
||||||
// Fallback path — deterministic tests pass without a scope-path lookup. The row
|
|
||||||
// is placed as a direct child of the trie root keyed by ScopeId.
|
|
||||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
|
||||||
|
|
||||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
var trie = PermissionTrieBuilder.Build(
|
||||||
|
"c1", 1, rows, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
||||||
|
|
||||||
// Root has one child — "line-42".
|
trie.Root.Children.ShouldNotContainKey("line-42",
|
||||||
trie.Root.Children.ShouldContainKey("line-42");
|
"the retired fallback planted the row here, where the realm-first walk can never reach it");
|
||||||
var node = trie.Root.Children["line-42"];
|
trie.Root.Children.ShouldBeEmpty();
|
||||||
node.Grants.Count.ShouldBe(1);
|
trie.Root.Grants.ShouldBeEmpty("an unresolvable row must not become a cluster-wide grant either");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Core-011 regression: when a sub-cluster row's ScopeId is not in the supplied
|
/// Core-011 regression: when a sub-cluster row's scope is not in the supplied <c>scopePaths</c>,
|
||||||
/// <c>scopePaths</c>, the fallback diagnostic callback must fire so the caller can
|
/// the diagnostic callback must fire so the caller can surface a warning. Silently dropping the
|
||||||
/// surface a warning. Silently dropping the grant under the wrong trie level is the
|
/// grant is the production hazard the finding flagged.
|
||||||
/// production hazard the finding flagged.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_Missing_ScopePath_Entry_Invokes_Diagnostic_Callback()
|
public void Build_Missing_ScopePath_Entry_Invokes_Diagnostic_Callback()
|
||||||
{
|
{
|
||||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
var paths = ScopePaths(
|
||||||
{
|
(NodeAclScopeKind.UnsLine, "line-known", UnsPath("area-1", "line-known")));
|
||||||
["line-known"] = new(new[] { "ns", "area-1", "line-known" }),
|
|
||||||
};
|
|
||||||
// Row references a line that is NOT in the path lookup.
|
// Row references a line that is NOT in the path lookup.
|
||||||
|
var orphan = Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read);
|
||||||
var rows = new[]
|
var rows = new[]
|
||||||
{
|
{
|
||||||
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read),
|
orphan,
|
||||||
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-known", NodePermissions.Read),
|
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-known", NodePermissions.Read),
|
||||||
};
|
};
|
||||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||||
@@ -114,45 +188,165 @@ public sealed class PermissionTrieBuilderTests
|
|||||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
|
||||||
|
|
||||||
diagnostics.Count.ShouldBe(1, "exactly one row had no matching scope-path entry");
|
diagnostics.Count.ShouldBe(1, "exactly one row had no matching scope-path entry");
|
||||||
|
diagnostics[0].NodeAclId.ShouldBe(orphan.NodeAclId);
|
||||||
diagnostics[0].ScopeId.ShouldBe("line-orphan");
|
diagnostics[0].ScopeId.ShouldBe("line-orphan");
|
||||||
diagnostics[0].ScopeKind.ShouldBe(NodeAclScopeKind.UnsLine);
|
diagnostics[0].ScopeKind.ShouldBe(NodeAclScopeKind.UnsLine);
|
||||||
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.MissingScopePath);
|
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
|
||||||
|
|
||||||
|
trie.CollectMatches(UnsScope("c1", "area-1", "line-known", "eq", "tag"), ["cn=ops"])
|
||||||
|
.Count.ShouldBe(1, "the resolvable row is still placed");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that no diagnostic is emitted when all sub-cluster rows have ScopePaths.</summary>
|
/// <summary>
|
||||||
|
/// The scope-path lookup is keyed on <c>(Kind, ScopeId)</c>, not the bare id: an entry filed under
|
||||||
|
/// one kind must not resolve a row of another kind that happens to share the id. <c>ScopeId</c> is
|
||||||
|
/// an unconstrained string, so id collisions across entity types are possible and resolving one
|
||||||
|
/// through the other is a silent wrong-node authorization.
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_No_Diagnostic_When_All_Sub_Cluster_Rows_Have_ScopePaths()
|
public void Build_ScopePath_Lookup_Is_Keyed_On_Kind_As_Well_As_Id()
|
||||||
{
|
{
|
||||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
var paths = ScopePaths(
|
||||||
{
|
(NodeAclScopeKind.UnsLine, "shared-id", UnsPath("area-1", "line-1")));
|
||||||
["line-A"] = new(new[] { "ns", "area-1", "line-A" }),
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "shared-id", NodePermissions.Read) };
|
||||||
["line-B"] = new(new[] { "ns", "area-1", "line-B" }),
|
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||||
};
|
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
|
||||||
|
|
||||||
|
diagnostics.Count.ShouldBe(1);
|
||||||
|
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
|
||||||
|
trie.CollectMatches(UnsScope("c1", "area-1", "line-1", "eq", "tag"), ["cn=ops"])
|
||||||
|
.ShouldBeEmpty("an Equipment row must not borrow a UnsLine entry's path");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that a diagnostic fires for the unresolvable row and for that row only. The negative
|
||||||
|
/// half on its own ("no diagnostics were emitted") passes for a <c>Build</c> that never emits any,
|
||||||
|
/// so the positive control lives in the same build rather than in a separate test.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Build_Diagnostic_Fires_For_The_Unresolvable_Row_Only()
|
||||||
|
{
|
||||||
|
var paths = ScopePaths(
|
||||||
|
(NodeAclScopeKind.UnsLine, "line-A", UnsPath("area-1", "line-A")),
|
||||||
|
(NodeAclScopeKind.UnsLine, "line-B", UnsPath("area-1", "line-B")));
|
||||||
|
var orphan = Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read);
|
||||||
var rows = new[]
|
var rows = new[]
|
||||||
{
|
{
|
||||||
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read), // cluster-level — no descent
|
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read), // cluster-level — never descends
|
||||||
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-A", NodePermissions.Read),
|
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-A", NodePermissions.Read),
|
||||||
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-B", NodePermissions.Read),
|
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-B", NodePermissions.Read),
|
||||||
|
orphan, // positive control
|
||||||
};
|
};
|
||||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||||
|
|
||||||
PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
|
||||||
|
|
||||||
diagnostics.ShouldBeEmpty("no rows are missing a scope-path entry");
|
diagnostics.Count.ShouldBe(1, "only the orphan row is unresolvable");
|
||||||
|
diagnostics[0].NodeAclId.ShouldBe(orphan.NodeAclId,
|
||||||
|
"the cluster row and the two resolvable line rows must not emit a diagnostic");
|
||||||
|
|
||||||
|
// …and the rows that did resolve are genuinely placed, so "one diagnostic" is not being reached
|
||||||
|
// by dropping everything.
|
||||||
|
trie.CollectMatches(UnsScope("c1", "area-1", "line-A", "eq", "tag"), ["cn=ops"])
|
||||||
|
.Count.ShouldBe(2, "the cluster grant plus the line-A grant");
|
||||||
|
trie.CollectMatches(UnsScope("c1", "area-1", "line-B", "eq", "tag"), ["cn=ops"])
|
||||||
|
.Count.ShouldBe(2, "the cluster grant plus the line-B grant");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that diagnostic callback is optional when ScopePaths is null.</summary>
|
/// <summary>
|
||||||
[Fact]
|
/// Replaces <c>Build_Diagnostic_Callback_Optional_When_ScopePaths_Null</c> — <c>scopePaths</c> is
|
||||||
public void Build_Diagnostic_Callback_Optional_When_ScopePaths_Null()
|
/// required now, so the mode that test described no longer exists. What still needs pinning is the
|
||||||
|
/// other authoring mistake: a sub-cluster row carrying no <c>ScopeId</c> names nothing, and used to
|
||||||
|
/// vanish with no signal at all.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public void Build_Sub_Cluster_Row_Without_ScopeId_Emits_MissingScopeId(string? scopeId)
|
||||||
{
|
{
|
||||||
// No diagnostics callback should fire when scopePaths itself is null — that's the
|
var row = Row("cn=ops", NodeAclScopeKind.UnsLine, scopeId, NodePermissions.Read);
|
||||||
// "deterministic-test fallback" mode, not a production drop.
|
|
||||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
|
|
||||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||||
|
|
||||||
PermissionTrieBuilder.Build("c1", 1, rows, scopePaths: null, diagnostic: diagnostics.Add);
|
var trie = PermissionTrieBuilder.Build(
|
||||||
|
"c1", 1, [row], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(), diagnostics.Add);
|
||||||
|
|
||||||
diagnostics.ShouldBeEmpty(
|
diagnostics.Count.ShouldBe(1);
|
||||||
"scopePaths=null is the explicit test-fallback mode and must not emit per-row warnings");
|
diagnostics[0].NodeAclId.ShouldBe(row.NodeAclId);
|
||||||
|
diagnostics[0].ScopeId.ShouldBeNull("the row had no scope id to report");
|
||||||
|
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.MissingScopeId);
|
||||||
|
trie.Root.Grants.ShouldBeEmpty("a scope-less sub-cluster row must not fall through to the cluster root");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="NodeAclScopeKind.Cluster"/> row never consults <c>scopePaths</c> and never emits a
|
||||||
|
/// diagnostic, even though it carries a null <c>ScopeId</c> — that is the one kind for which a null
|
||||||
|
/// id is correct.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Build_Cluster_Row_With_Null_ScopeId_Is_Placed_Without_Diagnostic()
|
||||||
|
{
|
||||||
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
|
||||||
|
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||||
|
|
||||||
|
var trie = PermissionTrieBuilder.Build(
|
||||||
|
"c1", 1, rows, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(), diagnostics.Add);
|
||||||
|
|
||||||
|
diagnostics.ShouldBeEmpty();
|
||||||
|
trie.Root.Grants.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A scope that resolves to several paths — a raw tag plus its UNS projections — plants the grant
|
||||||
|
/// at every one of them. A write routes to the same driver reference through either NodeId, so a
|
||||||
|
/// grant covering only one path would be bypassable through the other.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Build_Multi_Path_Scope_Plants_The_Grant_At_Every_Path()
|
||||||
|
{
|
||||||
|
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||||
|
{
|
||||||
|
[new AclScopeRef(NodeAclScopeKind.Tag, "tag-1")] = new[]
|
||||||
|
{
|
||||||
|
new NodeAclPath(RawPath("Plant", "Modbus", "dev1", "Speed")),
|
||||||
|
new NodeAclPath(UnsPath("area-1", "line-1", "eq-A", "Speed")),
|
||||||
|
new NodeAclPath(UnsPath("area-1", "line-1", "eq-B", "MotorSpeed")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Tag, "tag-1", NodePermissions.WriteOperate) };
|
||||||
|
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
|
trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"])
|
||||||
|
.Count.ShouldBe(1, "the raw NodeId");
|
||||||
|
trie.CollectMatches(UnsScope("c1", "area-1", "line-1", "eq-A", "Speed"), ["cn=ops"])
|
||||||
|
.Count.ShouldBe(1, "the first UNS projection");
|
||||||
|
trie.CollectMatches(UnsScope("c1", "area-1", "line-1", "eq-B", "MotorSpeed"), ["cn=ops"])
|
||||||
|
.Count.ShouldBe(1, "the second UNS projection, under its own effective name");
|
||||||
|
trie.CollectMatches(UnsScope("c1", "area-1", "line-1", "eq-B", "Speed"), ["cn=ops"])
|
||||||
|
.ShouldBeEmpty("eq-B projects the tag as MotorSpeed; the eq-A name must not match there");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An empty path list for a resolved key is treated the same as an absent key — dropped with an
|
||||||
|
/// <see cref="PermissionTrieBuildDiagnosticReason.UnresolvedScope"/> diagnostic rather than
|
||||||
|
/// silently planting the grant at the cluster root, which is where a zero-segment walk would leave
|
||||||
|
/// it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Build_Empty_Path_List_Is_Treated_As_Unresolved()
|
||||||
|
{
|
||||||
|
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||||
|
{
|
||||||
|
[new AclScopeRef(NodeAclScopeKind.Equipment, "eq-A")] = Array.Empty<NodeAclPath>(),
|
||||||
|
};
|
||||||
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.Read) };
|
||||||
|
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||||
|
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
|
||||||
|
|
||||||
|
diagnostics.Count.ShouldBe(1);
|
||||||
|
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
|
||||||
|
trie.Root.Grants.ShouldBeEmpty("an empty path list must not become a cluster-wide grant");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,18 @@ using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coverage for <see cref="PermissionTrie.CollectMatches"/> — the walk itself. Post-#520-stage-3 the
|
||||||
|
/// walk is <c>cluster → realm → …variable-length node path…</c>, so every non-cluster test here
|
||||||
|
/// supplies a real <c>scopePaths</c> entry whose path starts with a realm segment and drives a
|
||||||
|
/// <see cref="NodeScope.Segments"/> chain through it.
|
||||||
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
public sealed class PermissionTrieTests
|
public sealed class PermissionTrieTests
|
||||||
{
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
|
||||||
|
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
||||||
|
|
||||||
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags, string clusterId = "c1") =>
|
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags, string clusterId = "c1") =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
@@ -21,27 +30,36 @@ public sealed class PermissionTrieTests
|
|||||||
PermissionFlags = flags,
|
PermissionFlags = flags,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static NodeScope EquipmentTag(string cluster, string ns, string area, string line, string equip, string tag) =>
|
/// <summary>One scope resolving to one path.</summary>
|
||||||
new()
|
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths(
|
||||||
{
|
params (NodeAclScopeKind Kind, string ScopeId, string[] Segments)[] entries) =>
|
||||||
ClusterId = cluster,
|
entries.ToDictionary(
|
||||||
NamespaceId = ns,
|
e => new AclScopeRef(e.Kind, e.ScopeId),
|
||||||
UnsAreaId = area,
|
e => (IReadOnlyList<NodeAclPath>)new[] { new NodeAclPath(e.Segments) });
|
||||||
UnsLineId = line,
|
|
||||||
EquipmentId = equip,
|
/// <summary>A UNS-realm trie path: <c>uns / …segments…</c>.</summary>
|
||||||
TagId = tag,
|
private static string[] UnsPath(params string[] segments) => [NodeAclRealms.Uns, .. segments];
|
||||||
Kind = NodeHierarchyKind.Equipment,
|
|
||||||
};
|
/// <summary>A raw-realm trie path: <c>raw / …segments…</c>.</summary>
|
||||||
|
private static string[] RawPath(params string[] segments) => [NodeAclRealms.Raw, .. segments];
|
||||||
|
|
||||||
|
/// <summary>A node in the UNS realm addressed by its path below the realm segment.</summary>
|
||||||
|
private static NodeScope UnsScope(string cluster, params string[] segments) =>
|
||||||
|
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Uns, Segments = segments };
|
||||||
|
|
||||||
|
/// <summary>A node in the raw realm addressed by its path below the realm segment.</summary>
|
||||||
|
private static NodeScope RawScope(string cluster, params string[] segments) =>
|
||||||
|
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Raw, Segments = segments };
|
||||||
|
|
||||||
/// <summary>Verifies cluster-level grant cascades to every tag.</summary>
|
/// <summary>Verifies cluster-level grant cascades to every tag.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ClusterLevelGrant_Cascades_ToEveryTag()
|
public void ClusterLevelGrant_Cascades_ToEveryTag()
|
||||||
{
|
{
|
||||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, scopeId: null, NodePermissions.Read) };
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, scopeId: null, NodePermissions.Read) };
|
||||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, NoScopePaths);
|
||||||
|
|
||||||
var matches = trie.CollectMatches(
|
var matches = trie.CollectMatches(
|
||||||
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
UnsScope("c1", "area1", "line1", "eq1", "tag1"),
|
||||||
["cn=ops"]);
|
["cn=ops"]);
|
||||||
|
|
||||||
matches.Count.ShouldBe(1);
|
matches.Count.ShouldBe(1);
|
||||||
@@ -53,38 +71,44 @@ public sealed class PermissionTrieTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void EquipmentScope_DoesNotLeak_ToSibling()
|
public void EquipmentScope_DoesNotLeak_ToSibling()
|
||||||
{
|
{
|
||||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
var paths = ScopePaths(
|
||||||
{
|
(NodeAclScopeKind.Equipment, "eq-A", UnsPath("area1", "line1", "eq-A")));
|
||||||
["eq-A"] = new(new[] { "ns", "area1", "line1", "eq-A" }),
|
|
||||||
};
|
|
||||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.Read) };
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.Read) };
|
||||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
var matchA = trie.CollectMatches(EquipmentTag("c1", "ns", "area1", "line1", "eq-A", "tag1"), ["cn=ops"]);
|
var matchA = trie.CollectMatches(UnsScope("c1", "area1", "line1", "eq-A", "tag1"), ["cn=ops"]);
|
||||||
var matchB = trie.CollectMatches(EquipmentTag("c1", "ns", "area1", "line1", "eq-B", "tag1"), ["cn=ops"]);
|
var matchB = trie.CollectMatches(UnsScope("c1", "area1", "line1", "eq-B", "tag1"), ["cn=ops"]);
|
||||||
|
|
||||||
matchA.Count.ShouldBe(1);
|
matchA.Count.ShouldBe(1);
|
||||||
matchB.ShouldBeEmpty("grant at eq-A must not apply to sibling eq-B");
|
matchB.ShouldBeEmpty("grant at eq-A must not apply to sibling eq-B");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies multiple groups union their permission flags.</summary>
|
/// <summary>
|
||||||
|
/// Verifies that every principal-matching grant is collected once, with its own flags left
|
||||||
|
/// untouched. <see cref="PermissionTrie.CollectMatches"/> deliberately does NOT combine the flag
|
||||||
|
/// bits — the OR lives in <c>TriePermissionEvaluator.Authorize</c> and is pinned there by
|
||||||
|
/// <c>TriePermissionEvaluatorTests.Union_Across_Levels_Ors_Flags_From_Every_Match</c>. (Renamed
|
||||||
|
/// from <c>MultiGroup_Union_OrsPermissionFlags</c>, which OR-ed the flags in the test body and so
|
||||||
|
/// asserted nothing about production behaviour.)
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MultiGroup_Union_OrsPermissionFlags()
|
public void MultiGroup_Matches_Are_Collected_PerGrant_Without_Combining()
|
||||||
{
|
{
|
||||||
var rows = new[]
|
var rows = new[]
|
||||||
{
|
{
|
||||||
Row("cn=readers", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
|
Row("cn=readers", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
|
||||||
Row("cn=writers", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
|
Row("cn=writers", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
|
||||||
};
|
};
|
||||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, NoScopePaths);
|
||||||
|
|
||||||
var matches = trie.CollectMatches(
|
var matches = trie.CollectMatches(
|
||||||
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
UnsScope("c1", "area1", "line1", "eq1", "tag1"),
|
||||||
["cn=readers", "cn=writers"]);
|
["cn=readers", "cn=writers"]);
|
||||||
|
|
||||||
matches.Count.ShouldBe(2);
|
matches.Count.ShouldBe(2);
|
||||||
var combined = matches.Aggregate(NodePermissions.None, (acc, m) => acc | m.PermissionFlags);
|
matches.Select(m => m.Principal).ShouldBe(["cn=readers", "cn=writers"], ignoreOrder: true);
|
||||||
combined.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
|
matches.Single(m => m.Principal == "cn=readers").PermissionFlags.ShouldBe(NodePermissions.Read);
|
||||||
|
matches.Single(m => m.Principal == "cn=writers").PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies no matching group returns empty.</summary>
|
/// <summary>Verifies no matching group returns empty.</summary>
|
||||||
@@ -92,57 +116,52 @@ public sealed class PermissionTrieTests
|
|||||||
public void NoMatchingGroup_ReturnsEmpty()
|
public void NoMatchingGroup_ReturnsEmpty()
|
||||||
{
|
{
|
||||||
var rows = new[] { Row("cn=different", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
|
var rows = new[] { Row("cn=different", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
|
||||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, NoScopePaths);
|
||||||
|
|
||||||
var matches = trie.CollectMatches(
|
var matches = trie.CollectMatches(
|
||||||
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
UnsScope("c1", "area1", "line1", "eq1", "tag1"),
|
||||||
["cn=ops"]);
|
["cn=ops"]);
|
||||||
|
|
||||||
matches.ShouldBeEmpty();
|
matches.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Galaxy is now a standard Equipment-kind driver: Galaxy points are ordinary equipment
|
/// Galaxy is a standard Equipment-kind driver: Galaxy points are ordinary equipment tags, so a
|
||||||
/// tags, so a grant on a Galaxy equipment resolves via the Equipment walk and must not leak
|
/// grant on a Galaxy equipment resolves via the UNS walk and must not leak to a sibling Galaxy
|
||||||
/// to a sibling Galaxy equipment.
|
/// equipment.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Galaxy_EquipmentScope_Grant_DoesNotLeak_To_Sibling_Equipment()
|
public void Galaxy_EquipmentScope_Grant_DoesNotLeak_To_Sibling_Equipment()
|
||||||
{
|
{
|
||||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
var paths = ScopePaths(
|
||||||
{
|
(NodeAclScopeKind.Equipment, "gal-eq-A", UnsPath("area1", "line1", "gal-eq-A")));
|
||||||
["gal-eq-A"] = new(new[] { "ns-gal", "area1", "line1", "gal-eq-A" }),
|
|
||||||
};
|
|
||||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "gal-eq-A", NodePermissions.Read) };
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "gal-eq-A", NodePermissions.Read) };
|
||||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
var matchA = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq-A", "tag1"), ["cn=ops"]);
|
var matchA = trie.CollectMatches(UnsScope("c1", "area1", "line1", "gal-eq-A", "tag1"), ["cn=ops"]);
|
||||||
var matchB = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq-B", "tag1"), ["cn=ops"]);
|
var matchB = trie.CollectMatches(UnsScope("c1", "area1", "line1", "gal-eq-B", "tag1"), ["cn=ops"]);
|
||||||
|
|
||||||
matchA.Count.ShouldBe(1);
|
matchA.Count.ShouldBe(1);
|
||||||
matchB.ShouldBeEmpty("grant at gal-eq-A must not apply to sibling gal-eq-B");
|
matchB.ShouldBeEmpty("grant at gal-eq-A must not apply to sibling gal-eq-B");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A grant on a Galaxy equipment tag is reported with its true Equipment-walk structural
|
/// A grant on a Galaxy equipment tag is reported with the authored row's Equipment scope kind —
|
||||||
/// scope (Equipment), not a Galaxy-specific scope — Galaxy now flows through the same
|
/// Galaxy flows through the same UNS permission walk as every other equipment tag.
|
||||||
/// Equipment permission walk as every other equipment tag.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Galaxy_EquipmentScope_Grant_Reports_Equipment_Scope()
|
public void Galaxy_EquipmentScope_Grant_Reports_Equipment_Scope()
|
||||||
{
|
{
|
||||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
var paths = ScopePaths(
|
||||||
{
|
(NodeAclScopeKind.Equipment, "gal-eq", UnsPath("area1", "line1", "gal-eq")));
|
||||||
["gal-eq"] = new(new[] { "ns-gal", "area1", "line1", "gal-eq" }),
|
|
||||||
};
|
|
||||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "gal-eq", NodePermissions.Read) };
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "gal-eq", NodePermissions.Read) };
|
||||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
var matches = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq", "tag1"), ["cn=ops"]);
|
var matches = trie.CollectMatches(UnsScope("c1", "area1", "line1", "gal-eq", "tag1"), ["cn=ops"]);
|
||||||
|
|
||||||
matches.Count.ShouldBe(1);
|
matches.Count.ShouldBe(1);
|
||||||
matches[0].Scope.ShouldBe(NodeAclScopeKind.Equipment,
|
matches[0].Scope.ShouldBe(NodeAclScopeKind.Equipment,
|
||||||
"Galaxy equipment grants resolve via the Equipment walk and report Equipment scope");
|
"Galaxy equipment grants resolve via the UNS walk and report the row's Equipment scope");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies cross-cluster grant does not leak.</summary>
|
/// <summary>Verifies cross-cluster grant does not leak.</summary>
|
||||||
@@ -150,16 +169,21 @@ public sealed class PermissionTrieTests
|
|||||||
public void CrossCluster_Grant_DoesNotLeak()
|
public void CrossCluster_Grant_DoesNotLeak()
|
||||||
{
|
{
|
||||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read, clusterId: "c-other") };
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read, clusterId: "c-other") };
|
||||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, NoScopePaths);
|
||||||
|
|
||||||
var matches = trie.CollectMatches(
|
var matches = trie.CollectMatches(
|
||||||
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
UnsScope("c1", "area1", "line1", "eq1", "tag1"),
|
||||||
["cn=ops"]);
|
["cn=ops"]);
|
||||||
|
|
||||||
matches.ShouldBeEmpty("rows for cluster c-other must not land in c1's trie");
|
matches.ShouldBeEmpty("rows for cluster c-other must not land in c1's trie");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies build is idempotent.</summary>
|
/// <summary>
|
||||||
|
/// Verifies build is idempotent, against an <b>independent</b> expectation rather than by
|
||||||
|
/// comparing one build's output to the other's. The previous form asserted
|
||||||
|
/// <c>trie1.Count.ShouldBe(trie2.Count)</c>, which a <c>Build</c> that dropped every row passed as
|
||||||
|
/// <c>0 == 0</c>; the absolute counts below go red the moment placement regresses.
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_IsIdempotent()
|
public void Build_IsIdempotent()
|
||||||
{
|
{
|
||||||
@@ -167,13 +191,187 @@ public sealed class PermissionTrieTests
|
|||||||
{
|
{
|
||||||
Row("cn=a", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
|
Row("cn=a", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
|
||||||
Row("cn=b", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
|
Row("cn=b", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
|
||||||
|
Row("cn=a", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.WriteOperate),
|
||||||
};
|
};
|
||||||
|
var paths = ScopePaths(
|
||||||
|
(NodeAclScopeKind.Equipment, "eq-A", UnsPath("area1", "line1", "eq-A")));
|
||||||
|
var scope = UnsScope("c1", "area1", "line1", "eq-A", "tag1");
|
||||||
|
|
||||||
var trie1 = PermissionTrieBuilder.Build("c1", 1, rows);
|
var trie1 = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
var trie2 = PermissionTrieBuilder.Build("c1", 1, rows);
|
var trie2 = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
trie1.Root.Grants.Count.ShouldBe(trie2.Root.Grants.Count);
|
foreach (var trie in new[] { trie1, trie2 })
|
||||||
trie1.ClusterId.ShouldBe(trie2.ClusterId);
|
{
|
||||||
trie1.GenerationId.ShouldBe(trie2.GenerationId);
|
trie.ClusterId.ShouldBe("c1");
|
||||||
|
trie.GenerationId.ShouldBe(1);
|
||||||
|
trie.Root.Grants.Count.ShouldBe(2, "both cluster rows land at the root");
|
||||||
|
trie.CollectMatches(scope, ["cn=a"]).Count.ShouldBe(2, "one cluster grant + the eq-A grant");
|
||||||
|
trie.CollectMatches(scope, ["cn=b"]).Count.ShouldBe(1, "cn=b holds only the cluster grant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A grant planted at a raw container matches a tag nested arbitrarily deep beneath it — two
|
||||||
|
/// nested folders and two nested tag groups here — and does <b>not</b> match a sibling device.
|
||||||
|
/// The sibling leg is the negative control: without it a walk that ignored
|
||||||
|
/// <see cref="NodeScope.Segments"/> entirely would still pass the positive leg.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void RawDevice_Grant_Matches_Deeply_Nested_Tag_And_Not_A_Sibling_Device()
|
||||||
|
{
|
||||||
|
var paths = ScopePaths(
|
||||||
|
(NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "CellA", "Modbus", "dev1")));
|
||||||
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read) };
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
|
// raw / Plant / CellA / Modbus / dev1 / Motors / Drive / Speed — 7 segments below the realm.
|
||||||
|
var beneath = trie.CollectMatches(
|
||||||
|
RawScope("c1", "Plant", "CellA", "Modbus", "dev1", "Motors", "Drive", "Speed"),
|
||||||
|
["cn=ops"]);
|
||||||
|
|
||||||
|
var sibling = trie.CollectMatches(
|
||||||
|
RawScope("c1", "Plant", "CellA", "Modbus", "dev2", "Motors", "Drive", "Speed"),
|
||||||
|
["cn=ops"]);
|
||||||
|
|
||||||
|
beneath.Count.ShouldBe(1, "a container grant cascades to every node beneath it, at any depth");
|
||||||
|
beneath[0].Scope.ShouldBe(NodeAclScopeKind.RawDevice);
|
||||||
|
sibling.ShouldBeEmpty("grant at dev1 must not reach sibling device dev2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression guard for the <c>StringComparer.Ordinal</c> children comparer (#520 stage 3): a
|
||||||
|
/// grant authored on <c>Speed</c> must not match a scope for <c>speed</c>. Raw sibling names are
|
||||||
|
/// unique only under an ordinal comparer, so both spellings can exist under one device and the
|
||||||
|
/// previous <c>OrdinalIgnoreCase</c> map silently granted one through the other. The positive leg
|
||||||
|
/// is asserted alongside so the negative cannot pass by the walk simply never matching.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Segment_Matching_Is_Case_Sensitive()
|
||||||
|
{
|
||||||
|
var paths = ScopePaths(
|
||||||
|
(NodeAclScopeKind.Tag, "tag-speed", RawPath("Plant", "Modbus", "dev1", "Speed")));
|
||||||
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Tag, "tag-speed", NodePermissions.Read) };
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
|
var exact = trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"]);
|
||||||
|
var caseDrifted = trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "speed"), ["cn=ops"]);
|
||||||
|
|
||||||
|
exact.Count.ShouldBe(1, "the exact spelling must match, or the negative leg proves nothing");
|
||||||
|
caseDrifted.ShouldBeEmpty(
|
||||||
|
"segment matching is case-sensitive ordinal — 'Speed' and 'speed' are distinct raw siblings");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression guard for the case-sensitive comparer on the <b>container</b> levels too: a grant
|
||||||
|
/// under device <c>Dev1</c> must not cascade into a scope walking <c>dev1</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Container_Segment_Matching_Is_Case_Sensitive()
|
||||||
|
{
|
||||||
|
var paths = ScopePaths(
|
||||||
|
(NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "Modbus", "Dev1")));
|
||||||
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read) };
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
|
trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "Dev1", "Speed"), ["cn=ops"])
|
||||||
|
.Count.ShouldBe(1);
|
||||||
|
trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"])
|
||||||
|
.ShouldBeEmpty("a case-drifted container segment must not cascade");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The realm is a hard gate at level 1: a grant planted in the raw realm must not match a scope in
|
||||||
|
/// the UNS realm, even when the segments below the realm are identical. This pins that the walk
|
||||||
|
/// derives its level-1 key from <see cref="NodeScope.Kind"/> via
|
||||||
|
/// <see cref="NodeAclRealms.SegmentFor(NodeHierarchyKind)"/> rather than skipping the level.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Grant_In_One_Realm_DoesNot_Match_A_Scope_In_The_Other()
|
||||||
|
{
|
||||||
|
var paths = ScopePaths(
|
||||||
|
(NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "Modbus", "dev1")));
|
||||||
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read) };
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
|
trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"])
|
||||||
|
.Count.ShouldBe(1, "the raw scope must match, or the cross-realm leg proves nothing");
|
||||||
|
trie.CollectMatches(UnsScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"])
|
||||||
|
.ShouldBeEmpty("a raw-realm grant must not be reachable from a UNS scope");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="NodeAclScopeKind.Namespace"/> row planted at the realm segment itself grants every
|
||||||
|
/// node in that realm and nothing in the other one.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void NamespaceScope_Grant_Covers_Its_Realm_Only()
|
||||||
|
{
|
||||||
|
var paths = ScopePaths(
|
||||||
|
(NodeAclScopeKind.Namespace, NodeAclRealms.Raw, [NodeAclRealms.Raw]));
|
||||||
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Namespace, NodeAclRealms.Raw, NodePermissions.Read) };
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
|
var inRaw = trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"]);
|
||||||
|
var inUns = trie.CollectMatches(UnsScope("c1", "area1", "line1", "eq1", "tag1"), ["cn=ops"]);
|
||||||
|
|
||||||
|
inRaw.Count.ShouldBe(1);
|
||||||
|
inRaw[0].Scope.ShouldBe(NodeAclScopeKind.Namespace);
|
||||||
|
inUns.ShouldBeEmpty("a raw-namespace grant must not cover the UNS realm");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provenance is the granting <b>row's</b> <see cref="NodeAclScopeKind"/>, not one derived from the
|
||||||
|
/// structural depth the grant sits at. Two rows of different kinds planted at the same trie node
|
||||||
|
/// must report their own kinds — under any depth-derived scheme they would report the same one.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void MatchedGrant_Reports_The_Rows_Own_ScopeKind_Not_A_Depth_Derived_One()
|
||||||
|
{
|
||||||
|
var samePath = RawPath("Plant", "Modbus", "dev1");
|
||||||
|
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||||
|
{
|
||||||
|
[new AclScopeRef(NodeAclScopeKind.RawDevice, "dev-1")] = new[] { new NodeAclPath(samePath) },
|
||||||
|
[new AclScopeRef(NodeAclScopeKind.RawTagGroup, "grp-1")] = new[] { new NodeAclPath(samePath) },
|
||||||
|
};
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read),
|
||||||
|
Row("cn=ops", NodeAclScopeKind.RawTagGroup, "grp-1", NodePermissions.WriteOperate),
|
||||||
|
};
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
|
var matches = trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"]);
|
||||||
|
|
||||||
|
matches.Count.ShouldBe(2);
|
||||||
|
matches.Select(m => m.Scope).ShouldBe(
|
||||||
|
[NodeAclScopeKind.RawDevice, NodeAclScopeKind.RawTagGroup],
|
||||||
|
ignoreOrder: true,
|
||||||
|
"each grant carries the scope kind its own row was authored with");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A scope addressing the realm itself (no segments) collects the realm-level and cluster-level
|
||||||
|
/// grants and nothing planted below it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void RealmScope_With_No_Segments_Collects_Only_Cluster_And_Realm_Grants()
|
||||||
|
{
|
||||||
|
var paths = ScopePaths(
|
||||||
|
(NodeAclScopeKind.Namespace, NodeAclRealms.Raw, [NodeAclRealms.Raw]),
|
||||||
|
(NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "Modbus", "dev1")));
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Browse),
|
||||||
|
Row("cn=ops", NodeAclScopeKind.Namespace, NodeAclRealms.Raw, NodePermissions.Read),
|
||||||
|
Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.WriteOperate),
|
||||||
|
};
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
|
var matches = trie.CollectMatches(RawScope("c1"), ["cn=ops"]);
|
||||||
|
|
||||||
|
matches.Select(m => m.Scope).ShouldBe(
|
||||||
|
[NodeAclScopeKind.Cluster, NodeAclScopeKind.Namespace],
|
||||||
|
ignoreOrder: true,
|
||||||
|
"the device grant sits below the realm and must not be collected at the realm");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+174
-11
@@ -13,6 +13,9 @@ public sealed class TriePermissionEvaluatorTests
|
|||||||
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||||
private readonly FakeTimeProvider _time = new();
|
private readonly FakeTimeProvider _time = new();
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
|
||||||
|
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
||||||
|
|
||||||
private sealed class FakeTimeProvider : TimeProvider
|
private sealed class FakeTimeProvider : TimeProvider
|
||||||
{
|
{
|
||||||
/// <summary>Gets or sets the current UTC time.</summary>
|
/// <summary>Gets or sets the current UTC time.</summary>
|
||||||
@@ -46,22 +49,42 @@ public sealed class TriePermissionEvaluatorTests
|
|||||||
MembershipVersion = 1,
|
MembershipVersion = 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>One scope resolving to one path.</summary>
|
||||||
|
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths(
|
||||||
|
params (NodeAclScopeKind Kind, string ScopeId, string[] Segments)[] entries) =>
|
||||||
|
entries.ToDictionary(
|
||||||
|
e => new AclScopeRef(e.Kind, e.ScopeId),
|
||||||
|
e => (IReadOnlyList<NodeAclPath>)new[] { new NodeAclPath(e.Segments) });
|
||||||
|
|
||||||
|
/// <summary>A UNS-realm trie path: <c>uns / …segments…</c>.</summary>
|
||||||
|
private static string[] UnsPath(params string[] segments) => [NodeAclRealms.Uns, .. segments];
|
||||||
|
|
||||||
|
/// <summary>A raw-realm trie path: <c>raw / …segments…</c>.</summary>
|
||||||
|
private static string[] RawPath(params string[] segments) => [NodeAclRealms.Raw, .. segments];
|
||||||
|
|
||||||
|
/// <summary>The default UNS node the flag/staleness/generation tests evaluate against.</summary>
|
||||||
private static NodeScope Scope(string cluster = "c1") =>
|
private static NodeScope Scope(string cluster = "c1") =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
ClusterId = cluster,
|
ClusterId = cluster,
|
||||||
NamespaceId = "ns",
|
Kind = NodeHierarchyKind.Uns,
|
||||||
UnsAreaId = "area",
|
Segments = ["area", "line", "eq", "tag"],
|
||||||
UnsLineId = "line",
|
|
||||||
EquipmentId = "eq",
|
|
||||||
TagId = "tag",
|
|
||||||
Kind = NodeHierarchyKind.Equipment,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private TriePermissionEvaluator MakeEvaluator(NodeAcl[] rows)
|
/// <summary>A node in the UNS realm addressed by its path below the realm segment.</summary>
|
||||||
|
private static NodeScope UnsScope(string cluster, params string[] segments) =>
|
||||||
|
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Uns, Segments = segments };
|
||||||
|
|
||||||
|
/// <summary>A node in the raw realm addressed by its path below the realm segment.</summary>
|
||||||
|
private static NodeScope RawScope(string cluster, params string[] segments) =>
|
||||||
|
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Raw, Segments = segments };
|
||||||
|
|
||||||
|
private TriePermissionEvaluator MakeEvaluator(
|
||||||
|
NodeAcl[] rows,
|
||||||
|
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>? scopePaths = null)
|
||||||
{
|
{
|
||||||
var cache = new PermissionTrieCache();
|
var cache = new PermissionTrieCache();
|
||||||
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows));
|
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows, scopePaths ?? NoScopePaths));
|
||||||
return new TriePermissionEvaluator(cache, _time);
|
return new TriePermissionEvaluator(cache, _time);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +183,8 @@ public sealed class TriePermissionEvaluatorTests
|
|||||||
// The evaluator must still honour the session's bound generation 1, not generation 2.
|
// The evaluator must still honour the session's bound generation 1, not generation 2.
|
||||||
var gen1Row = Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read);
|
var gen1Row = Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read);
|
||||||
var cache = new PermissionTrieCache();
|
var cache = new PermissionTrieCache();
|
||||||
cache.Install(PermissionTrieBuilder.Build("c1", 1, [gen1Row]));
|
cache.Install(PermissionTrieBuilder.Build("c1", 1, [gen1Row], NoScopePaths));
|
||||||
cache.Install(PermissionTrieBuilder.Build("c1", 2, [])); // gen 2 — grant revoked, now current
|
cache.Install(PermissionTrieBuilder.Build("c1", 2, [], NoScopePaths)); // gen 2 — grant revoked, now current
|
||||||
var evaluator = new TriePermissionEvaluator(cache, _time);
|
var evaluator = new TriePermissionEvaluator(cache, _time);
|
||||||
|
|
||||||
var decision = evaluator.Authorize(
|
var decision = evaluator.Authorize(
|
||||||
@@ -180,7 +203,7 @@ public sealed class TriePermissionEvaluatorTests
|
|||||||
// The evaluator must fail closed rather than silently using the current generation.
|
// The evaluator must fail closed rather than silently using the current generation.
|
||||||
var gen5Row = Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read);
|
var gen5Row = Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read);
|
||||||
var cache = new PermissionTrieCache();
|
var cache = new PermissionTrieCache();
|
||||||
cache.Install(PermissionTrieBuilder.Build("c1", 5, [gen5Row]));
|
cache.Install(PermissionTrieBuilder.Build("c1", 5, [gen5Row], NoScopePaths));
|
||||||
var evaluator = new TriePermissionEvaluator(cache, _time);
|
var evaluator = new TriePermissionEvaluator(cache, _time);
|
||||||
|
|
||||||
// Session bound to generation 1, which was never installed / has been pruned.
|
// Session bound to generation 1, which was never installed / has been pruned.
|
||||||
@@ -234,4 +257,144 @@ public sealed class TriePermissionEvaluatorTests
|
|||||||
TriePermissionEvaluator.MapOperationToPermission(op);
|
TriePermissionEvaluator.MapOperationToPermission(op);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end coverage of the sub-cluster walk: an Equipment-scoped grant authorizes a node
|
||||||
|
/// beneath that equipment, and denies the same operation on a sibling equipment. Every other test
|
||||||
|
/// in this class uses a cluster-scoped grant, which the walk collects at level 0 before it ever
|
||||||
|
/// descends — so without this pair nothing here exercises <see cref="NodeScope.Segments"/> at all.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SubCluster_Grant_Allows_Beneath_Its_Equipment_And_Denies_A_Sibling()
|
||||||
|
{
|
||||||
|
var evaluator = MakeEvaluator(
|
||||||
|
[Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.WriteOperate)],
|
||||||
|
ScopePaths((NodeAclScopeKind.Equipment, "eq-A", UnsPath("area-1", "line-1", "eq-A"))));
|
||||||
|
|
||||||
|
var onEquipment = evaluator.Authorize(
|
||||||
|
Session(["cn=ops"]), OpcUaOperation.WriteOperate,
|
||||||
|
UnsScope("c1", "area-1", "line-1", "eq-A", "Setpoint"));
|
||||||
|
|
||||||
|
var onSibling = evaluator.Authorize(
|
||||||
|
Session(["cn=ops"]), OpcUaOperation.WriteOperate,
|
||||||
|
UnsScope("c1", "area-1", "line-1", "eq-B", "Setpoint"));
|
||||||
|
|
||||||
|
onEquipment.IsAllowed.ShouldBeTrue("the grant sits on the equipment the scope walks through");
|
||||||
|
onEquipment.Provenance.Count.ShouldBe(1);
|
||||||
|
onEquipment.Provenance[0].Scope.ShouldBe(NodeAclScopeKind.Equipment);
|
||||||
|
onSibling.IsAllowed.ShouldBeFalse("a grant on eq-A must not authorize eq-B");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The same walk through the raw realm, over a path deep enough to have nested folders and nested
|
||||||
|
/// tag groups: a grant on the device authorizes a tag six levels below it, and not the sibling
|
||||||
|
/// device's tag.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SubCluster_Grant_Walks_A_Deep_Raw_Path()
|
||||||
|
{
|
||||||
|
var evaluator = MakeEvaluator(
|
||||||
|
[Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read)],
|
||||||
|
ScopePaths((NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "CellA", "Modbus", "dev1"))));
|
||||||
|
|
||||||
|
var beneath = evaluator.Authorize(
|
||||||
|
Session(["cn=ops"]), OpcUaOperation.Read,
|
||||||
|
RawScope("c1", "Plant", "CellA", "Modbus", "dev1", "Motors", "Drive", "Speed"));
|
||||||
|
|
||||||
|
var sibling = evaluator.Authorize(
|
||||||
|
Session(["cn=ops"]), OpcUaOperation.Read,
|
||||||
|
RawScope("c1", "Plant", "CellA", "Modbus", "dev2", "Motors", "Drive", "Speed"));
|
||||||
|
|
||||||
|
beneath.IsAllowed.ShouldBeTrue();
|
||||||
|
beneath.Provenance[0].Scope.ShouldBe(NodeAclScopeKind.RawDevice,
|
||||||
|
"provenance names the row's own kind, not one derived from the depth it was planted at");
|
||||||
|
sibling.IsAllowed.ShouldBeFalse("the grant is anchored at dev1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The permission OR lives here, in <c>Authorize</c>: flags from grants matched at different trie
|
||||||
|
/// levels combine into one bitmask. Both legs are asserted with the same session and scope, so
|
||||||
|
/// neither "use the first match" nor "use the last match" can pass.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Union_Across_Levels_Ors_Flags_From_Every_Match()
|
||||||
|
{
|
||||||
|
var evaluator = MakeEvaluator(
|
||||||
|
[
|
||||||
|
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
|
||||||
|
Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.WriteOperate),
|
||||||
|
],
|
||||||
|
ScopePaths((NodeAclScopeKind.Equipment, "eq-A", UnsPath("area-1", "line-1", "eq-A"))));
|
||||||
|
|
||||||
|
var scope = UnsScope("c1", "area-1", "line-1", "eq-A", "Setpoint");
|
||||||
|
|
||||||
|
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, scope)
|
||||||
|
.IsAllowed.ShouldBeTrue("Read comes from the cluster-level grant");
|
||||||
|
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.WriteOperate, scope)
|
||||||
|
.IsAllowed.ShouldBeTrue("WriteOperate comes from the equipment-level grant");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The union also spans principals: a session carrying two principals gets the OR of both their
|
||||||
|
/// grants, and neither principal alone carries both permissions.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Union_Across_Principals_Ors_Flags()
|
||||||
|
{
|
||||||
|
NodeAcl[] rows =
|
||||||
|
[
|
||||||
|
Row("cn=readers", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
|
||||||
|
Row("cn=writers", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
|
||||||
|
];
|
||||||
|
var evaluator = MakeEvaluator(rows);
|
||||||
|
|
||||||
|
evaluator.Authorize(Session(["cn=readers", "cn=writers"]), OpcUaOperation.WriteOperate, Scope())
|
||||||
|
.IsAllowed.ShouldBeTrue();
|
||||||
|
evaluator.Authorize(Session(["cn=readers", "cn=writers"]), OpcUaOperation.Read, Scope())
|
||||||
|
.IsAllowed.ShouldBeTrue();
|
||||||
|
evaluator.Authorize(Session(["cn=readers"]), OpcUaOperation.WriteOperate, Scope())
|
||||||
|
.IsAllowed.ShouldBeFalse("cn=readers alone carries no write bit");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A grant resolved into the raw realm must not authorize the UNS node of the same name. The realm
|
||||||
|
/// is level 1 of the walk and is derived from <see cref="NodeScope.Kind"/>, so a scope in the wrong
|
||||||
|
/// realm never reaches the grant.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Grant_In_The_Raw_Realm_DoesNot_Authorize_A_Uns_Scope()
|
||||||
|
{
|
||||||
|
var evaluator = MakeEvaluator(
|
||||||
|
[Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read)],
|
||||||
|
ScopePaths((NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "Modbus", "dev1"))));
|
||||||
|
|
||||||
|
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read,
|
||||||
|
RawScope("c1", "Plant", "Modbus", "dev1", "Speed"))
|
||||||
|
.IsAllowed.ShouldBeTrue("the raw scope must be allowed, or the cross-realm leg proves nothing");
|
||||||
|
|
||||||
|
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read,
|
||||||
|
UnsScope("c1", "Plant", "Modbus", "dev1", "Speed"))
|
||||||
|
.IsAllowed.ShouldBeFalse("a raw-realm grant must not be reachable from a UNS scope");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A case-drifted scope segment must not reach a grant — the trie's children compare with
|
||||||
|
/// <see cref="StringComparer.Ordinal"/>, matching OPC UA NodeId identity, so <c>Speed</c> and
|
||||||
|
/// <c>speed</c> are distinct nodes and a grant on one denies the other.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Case_Drifted_Scope_Segment_Is_NotGranted()
|
||||||
|
{
|
||||||
|
var evaluator = MakeEvaluator(
|
||||||
|
[Row("cn=ops", NodeAclScopeKind.Tag, "tag-speed", NodePermissions.Read)],
|
||||||
|
ScopePaths((NodeAclScopeKind.Tag, "tag-speed", RawPath("Plant", "Modbus", "dev1", "Speed"))));
|
||||||
|
|
||||||
|
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read,
|
||||||
|
RawScope("c1", "Plant", "Modbus", "dev1", "Speed"))
|
||||||
|
.IsAllowed.ShouldBeTrue();
|
||||||
|
|
||||||
|
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read,
|
||||||
|
RawScope("c1", "Plant", "Modbus", "dev1", "speed"))
|
||||||
|
.IsAllowed.ShouldBeFalse("segment matching is case-sensitive ordinal");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -44,8 +44,11 @@ public class PageAuthorizationGuardTests
|
|||||||
[typeof(Scripts)] = AdminUiPolicies.ConfigEditor,
|
[typeof(Scripts)] = AdminUiPolicies.ConfigEditor,
|
||||||
[typeof(ScriptEdit)] = AdminUiPolicies.ConfigEditor,
|
[typeof(ScriptEdit)] = AdminUiPolicies.ConfigEditor,
|
||||||
|
|
||||||
// ── FleetAdmin (1) ──
|
// ── FleetAdmin (2) ──
|
||||||
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
|
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
|
||||||
|
// Minting a key hands out a data-plane credential, and the secret is shown once at creation —
|
||||||
|
// the same authority level as editing role grants.
|
||||||
|
[typeof(ApiKeys)] = AdminUiPolicies.FleetAdmin,
|
||||||
|
|
||||||
// ── Secrets (1): host wrapper for the ZB.MOM.WW.Secrets.Ui RCL page (lmxopcua#483) —
|
// ── Secrets (1): host wrapper for the ZB.MOM.WW.Secrets.Ui RCL page (lmxopcua#483) —
|
||||||
// carries the RCL page's own policy, registered additively by AddAdminUI via
|
// carries the RCL page's own policy, registered additively by AddAdminUI via
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ public sealed class ArtifactApiKeyStoreTests
|
|||||||
{
|
{
|
||||||
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
|
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No test here exercises a narrower-than-Cluster scope, so the resolved scope paths are empty.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
|
||||||
|
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_node_that_has_not_been_deployed_to_authenticates_nothing()
|
public async Task A_node_that_has_not_been_deployed_to_authenticates_nothing()
|
||||||
{
|
{
|
||||||
@@ -29,7 +35,7 @@ public sealed class ArtifactApiKeyStoreTests
|
|||||||
var state = new DataPlaneAuthorityState();
|
var state = new DataPlaneAuthorityState();
|
||||||
state.Apply("cluster-a", "rev-1",
|
state.Apply("cluster-a", "rev-1",
|
||||||
[Key("abc", "scadabridge-dcl")],
|
[Key("abc", "scadabridge-dcl")],
|
||||||
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
|
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], NoScopePaths);
|
||||||
|
|
||||||
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
||||||
|
|
||||||
@@ -41,16 +47,18 @@ public sealed class ArtifactApiKeyStoreTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Only_cluster_scoped_grants_are_returned()
|
public async Task Only_cluster_scoped_grants_are_returned()
|
||||||
{
|
{
|
||||||
// Matches ConfigDbApiKeyStore, which filters the same way in SQL. Narrower scopes are not yet
|
// Narrower scopes are not yet enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately
|
||||||
// enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately under-grants rather than
|
// under-grants rather than promoting a scoped grant's flags to server-wide authority. Scope kind
|
||||||
// promoting a scoped grant's flags to server-wide authority.
|
// 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();
|
var state = new DataPlaneAuthorityState();
|
||||||
state.Apply("cluster-a", "rev-1",
|
state.Apply("cluster-a", "rev-1",
|
||||||
[Key("abc", "p")],
|
[Key("abc", "p")],
|
||||||
[
|
[
|
||||||
Acl("acl-cluster", "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
|
Acl("acl-cluster", "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
|
||||||
Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
|
Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
|
||||||
]);
|
], NoScopePaths);
|
||||||
|
|
||||||
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
||||||
|
|
||||||
@@ -65,12 +73,12 @@ public sealed class ArtifactApiKeyStoreTests
|
|||||||
var state = new DataPlaneAuthorityState();
|
var state = new DataPlaneAuthorityState();
|
||||||
var sut = new ArtifactApiKeyStore(state);
|
var sut = new ArtifactApiKeyStore(state);
|
||||||
|
|
||||||
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
|
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], [], NoScopePaths);
|
||||||
(await sut.FindAsync("abc", Now, default)).ShouldNotBeNull();
|
(await sut.FindAsync("abc", Now, default)).ShouldNotBeNull();
|
||||||
|
|
||||||
var revoked = Key("abc", "p");
|
var revoked = Key("abc", "p");
|
||||||
revoked.Enabled = false;
|
revoked.Enabled = false;
|
||||||
state.Apply("cluster-a", "rev-2", [revoked], []);
|
state.Apply("cluster-a", "rev-2", [revoked], [], NoScopePaths);
|
||||||
|
|
||||||
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
|
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
|
||||||
}
|
}
|
||||||
@@ -81,7 +89,8 @@ public sealed class ArtifactApiKeyStoreTests
|
|||||||
// The anonymous principal has grants but obviously no credential, so FindGrantsAsync is its path.
|
// The anonymous principal has grants but obviously no credential, so FindGrantsAsync is its path.
|
||||||
var state = new DataPlaneAuthorityState();
|
var state = new DataPlaneAuthorityState();
|
||||||
state.Apply("cluster-a", "rev-1", [],
|
state.Apply("cluster-a", "rev-1", [],
|
||||||
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)]);
|
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)],
|
||||||
|
NoScopePaths);
|
||||||
|
|
||||||
var grants = await new ArtifactApiKeyStore(state).FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
var grants = await new ArtifactApiKeyStore(state).FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||||
|
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.Host.OpcUa;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The key source for a node WITH a ConfigDb (#520 stage 2) — an admin-role node, including a fused
|
||||||
|
/// admin+driver one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why cluster confinement is tested here and not on <c>ArtifactApiKeyStore</c>.</b> The two
|
||||||
|
/// stores read different things. The artifact store's snapshot was already narrowed to the node's
|
||||||
|
/// own cluster by <c>DeploymentArtifact.ParseDataPlaneAuthority</c>
|
||||||
|
/// (<c>ClusterFilterMode.ScopeTo</c>), so it cannot see another cluster's rows at all. This store
|
||||||
|
/// queries the fleet-wide ConfigDb, so the confinement is its own job — and until it was added, a
|
||||||
|
/// Cluster-scoped grant authored in ANY cluster conferred an interim server-wide role on a fused
|
||||||
|
/// central node.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The in-memory EF provider is the convention in the ControlPlane/Runtime test projects; nothing
|
||||||
|
/// here touches the central SQL Server. Note it compares strings with LINQ-to-Objects ordinal
|
||||||
|
/// semantics, NOT SQL Server's case-insensitive collation — which is exactly why the ClusterId
|
||||||
|
/// match is done explicitly with <see cref="StringComparison.OrdinalIgnoreCase"/> in the store
|
||||||
|
/// rather than left to the provider. <see cref="A_ClusterId_matches_case_insensitively"/> pins it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ConfigDbApiKeyStoreTests
|
||||||
|
{
|
||||||
|
private const string ThisCluster = "SITE-A";
|
||||||
|
private const string OtherCluster = "SITE-B";
|
||||||
|
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// THE REGRESSION TEST. A Cluster-scoped grant authored in another cluster must not reach this
|
||||||
|
/// node — it would collapse into a server-wide <c>WriteOperate</c> role via
|
||||||
|
/// <c>ApiKeyInterimRoleMapper</c>, i.e. write authority granted in SITE-B taking effect in SITE-A.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_cluster_scoped_grant_from_another_cluster_confers_nothing()
|
||||||
|
{
|
||||||
|
var factory = NewInMemoryDbFactory();
|
||||||
|
Seed(factory,
|
||||||
|
keys: [Key("abc", "scadabridge-dcl")],
|
||||||
|
acls: [Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
|
||||||
|
|
||||||
|
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
|
||||||
|
|
||||||
|
// The KEY still resolves — keys are fleet-wide credentials (ApiKey carries no ClusterId); it is
|
||||||
|
// the grants that are cluster-bound. Asserting a null lookup here would pass for the wrong reason.
|
||||||
|
found.ShouldNotBeNull();
|
||||||
|
found!.Grants.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The positive control for the test above: without it, a "fix" that returned no grants at all
|
||||||
|
/// would pass.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_cluster_scoped_grant_in_this_cluster_still_confers_its_permissions()
|
||||||
|
{
|
||||||
|
var factory = NewInMemoryDbFactory();
|
||||||
|
Seed(factory,
|
||||||
|
keys: [Key("abc", "scadabridge-dcl")],
|
||||||
|
acls:
|
||||||
|
[
|
||||||
|
Acl("acl-mine", ThisCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
||||||
|
Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
||||||
|
]);
|
||||||
|
|
||||||
|
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
|
||||||
|
|
||||||
|
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A legacy / single-cluster node carries no <c>cluster-{ClusterId}</c> role, so its ClusterId is
|
||||||
|
/// null and NO filter applies — mirroring the artifact path's <c>ClusterFilterMode.None</c>.
|
||||||
|
/// Failing closed here would silently strip every grant on an existing deployment.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_node_with_no_cluster_scoped_role_sees_every_cluster_scoped_grant()
|
||||||
|
{
|
||||||
|
var factory = NewInMemoryDbFactory();
|
||||||
|
Seed(factory,
|
||||||
|
keys: [Key("abc", "p")],
|
||||||
|
acls:
|
||||||
|
[
|
||||||
|
Acl("acl-a", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
|
||||||
|
Acl("acl-b", OtherCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
||||||
|
]);
|
||||||
|
|
||||||
|
var found = await new ConfigDbApiKeyStore(factory, clusterId: null).FindAsync("abc", Now, default);
|
||||||
|
|
||||||
|
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-a", "acl-b"], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The node's ClusterId comes from its role suffix, and that arrives in either the operator's
|
||||||
|
/// spelling (<c>Cluster:Roles</c> binds verbatim) or lower-cased (<c>OTOPCUA_ROLES</c> goes through
|
||||||
|
/// <c>RoleParser.Parse</c>, which calls <c>ToLowerInvariant</c>). The ACL row's ClusterId is
|
||||||
|
/// authored independently. A case-sensitive match would therefore strip every grant on a node
|
||||||
|
/// configured the second way — so the match is OrdinalIgnoreCase, the same comparison
|
||||||
|
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> uses.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_ClusterId_matches_case_insensitively()
|
||||||
|
{
|
||||||
|
var factory = NewInMemoryDbFactory();
|
||||||
|
Seed(factory,
|
||||||
|
keys: [Key("abc", "p")],
|
||||||
|
acls: [Acl("acl-mine", "SITE-A", "p", NodeAclScopeKind.Cluster, NodePermissions.Read)]);
|
||||||
|
|
||||||
|
var found = await new ConfigDbApiKeyStore(factory, "site-a").FindAsync("abc", Now, default);
|
||||||
|
|
||||||
|
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>FindGrantsAsync</c> is the anonymous principal's path — no credential, so it never goes
|
||||||
|
/// through <c>FindAsync</c>. It must be confined to the node's cluster too.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Anonymous_grants_are_cluster_confined_on_their_own_path()
|
||||||
|
{
|
||||||
|
var factory = NewInMemoryDbFactory();
|
||||||
|
Seed(factory,
|
||||||
|
keys: [],
|
||||||
|
acls:
|
||||||
|
[
|
||||||
|
Acl("acl-anon-mine", ThisCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read),
|
||||||
|
Acl("acl-anon-other", OtherCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
||||||
|
]);
|
||||||
|
|
||||||
|
var grants = await new ConfigDbApiKeyStore(factory, ThisCluster)
|
||||||
|
.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
||||||
|
|
||||||
|
grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-anon-mine");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_narrower_scope_in_this_cluster_is_still_excluded()
|
||||||
|
{
|
||||||
|
var factory = NewInMemoryDbFactory();
|
||||||
|
Seed(factory,
|
||||||
|
keys: [Key("abc", "p")],
|
||||||
|
acls:
|
||||||
|
[
|
||||||
|
Acl("acl-cluster", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ClusterId derivation itself: the same one <c>ClusterRoleInfo</c> performs for
|
||||||
|
/// <c>IClusterRoleInfo.ClusterId</c>, off the node's OWN configured roles.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ResolveClusterId_reads_the_nodes_own_cluster_scoped_role()
|
||||||
|
{
|
||||||
|
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["driver", "cluster-SITE-A"] })
|
||||||
|
.ShouldBe("SITE-A");
|
||||||
|
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["admin", "driver"] })
|
||||||
|
.ShouldBeNull();
|
||||||
|
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions())
|
||||||
|
.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Seed(
|
||||||
|
IDbContextFactory<OtOpcUaConfigDbContext> factory,
|
||||||
|
IReadOnlyList<ApiKey> keys,
|
||||||
|
IReadOnlyList<NodeAcl> acls)
|
||||||
|
{
|
||||||
|
using var db = factory.CreateDbContext();
|
||||||
|
db.ApiKeys.AddRange(keys);
|
||||||
|
db.NodeAcls.AddRange(acls);
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApiKey Key(string id, string principal) => new()
|
||||||
|
{
|
||||||
|
ApiKeyRowId = Guid.NewGuid(),
|
||||||
|
ApiKeyId = id,
|
||||||
|
Name = id,
|
||||||
|
SecretHash = "hash",
|
||||||
|
Principal = principal,
|
||||||
|
Enabled = true,
|
||||||
|
CreatedUtc = Now.AddDays(-1),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static NodeAcl Acl(
|
||||||
|
string id, string clusterId, string principal, NodeAclScopeKind scope, NodePermissions flags) => new()
|
||||||
|
{
|
||||||
|
NodeAclRowId = Guid.NewGuid(),
|
||||||
|
NodeAclId = id,
|
||||||
|
ClusterId = clusterId,
|
||||||
|
Principal = principal,
|
||||||
|
ScopeKind = scope,
|
||||||
|
PermissionFlags = flags,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IDbContextFactory<OtOpcUaConfigDbContext> NewInMemoryDbFactory() =>
|
||||||
|
new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||||
|
{
|
||||||
|
/// <summary>Creates a context over this test's own in-memory database.</summary>
|
||||||
|
/// <returns>A new <see cref="OtOpcUaConfigDbContext"/>.</returns>
|
||||||
|
public OtOpcUaConfigDbContext CreateDbContext() =>
|
||||||
|
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(dbName).Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="xunit"/>
|
<PackageReference Include="xunit"/>
|
||||||
<PackageReference Include="Shouldly"/>
|
<PackageReference Include="Shouldly"/>
|
||||||
|
<!-- ConfigDbApiKeyStoreTests drives the store against a real EF query, the same in-memory
|
||||||
|
provider convention the ControlPlane/Runtime test projects use. No central SQL Server. -->
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||||
<PackageReference Include="xunit.runner.visualstudio">
|
<PackageReference Include="xunit.runner.visualstudio">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
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.Runtime.Drivers;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end coverage of the ACL scope path as it actually runs on a node (#520 stage 3):
|
||||||
|
/// artifact bytes → <see cref="DeploymentArtifact.ParseDataPlaneAuthority"/> → scope paths →
|
||||||
|
/// <see cref="PermissionTrieBuilder.Build"/> → <see cref="PermissionTrie.CollectMatches"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the coverage the design doc called out as missing, and it was missing in a specific
|
||||||
|
/// way worth remembering: the pre-stage-3 tests all hand-built the <c>scopePaths</c> dictionary in
|
||||||
|
/// the test body with exactly the segments the walker expected, so the walker was pinned against
|
||||||
|
/// the test's idea of the format rather than against any producer. Nothing proved a real artifact
|
||||||
|
/// could yield a matching grant, and in fact nothing could have — there was no producer at all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// So the artifact JSON here is deliberately hand-written rather than composed: it is standing in
|
||||||
|
/// for the wire format, and the parser must agree with <em>it</em>. The composer→parser agreement
|
||||||
|
/// is pinned separately, in <c>ControlPlane.Tests/DataPlaneAuthorityArtifactTests</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DeploymentArtifactScopePathTests
|
||||||
|
{
|
||||||
|
private const string NodeId = "node-a:4840";
|
||||||
|
|
||||||
|
// Plant/Line1 → ModbusDrv → dev1 → Fast → Speed, projected into equipment West/L1/Mixer as "MixerSpeed".
|
||||||
|
private const string Artifact = """
|
||||||
|
{
|
||||||
|
"RawFolders": [
|
||||||
|
{"RawFolderId":"RF-plant","ClusterId":"c1","ParentRawFolderId":null,"Name":"Plant"},
|
||||||
|
{"RawFolderId":"RF-line1","ClusterId":"c1","ParentRawFolderId":"RF-plant","Name":"Line1"}
|
||||||
|
],
|
||||||
|
"DriverInstances": [
|
||||||
|
{"DriverInstanceId":"DI-modbus","ClusterId":"c1","RawFolderId":"RF-line1","Name":"ModbusDrv"}
|
||||||
|
],
|
||||||
|
"Devices": [{"DeviceId":"DV-1","DriverInstanceId":"DI-modbus","Name":"dev1"}],
|
||||||
|
"TagGroups": [{"TagGroupId":"TG-fast","DeviceId":"DV-1","ParentTagGroupId":null,"Name":"Fast"}],
|
||||||
|
"Tags": [{"TagId":"TAG-speed","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Speed"}],
|
||||||
|
"UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}],
|
||||||
|
"UnsLines": [{"UnsLineId":"UL-1","UnsAreaId":"UA-w","Name":"L1"}],
|
||||||
|
"Equipment": [{"EquipmentId":"EQ-mixer","UnsLineId":"UL-1","Name":"Mixer"}],
|
||||||
|
"UnsTagReferences": [
|
||||||
|
{"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed","DisplayNameOverride":"MixerSpeed"}
|
||||||
|
],
|
||||||
|
"NodeAcls": [
|
||||||
|
{"NodeAclId":"acl-dev","ClusterId":"c1","Principal":"p","ScopeKind":9,"ScopeId":"DV-1","PermissionFlags":2},
|
||||||
|
{"NodeAclId":"acl-eq","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16},
|
||||||
|
{"NodeAclId":"acl-dangling","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-gone","PermissionFlags":16}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static (PermissionTrie Trie, List<PermissionTrieBuildDiagnostic> Diagnostics) BuildFromArtifact()
|
||||||
|
{
|
||||||
|
var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId);
|
||||||
|
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||||
|
var trie = PermissionTrieBuilder.Build("c1", 1, projection.Grants, projection.ScopePaths, diagnostics.Add);
|
||||||
|
return (trie, diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_raw_device_grant_from_the_artifact_matches_a_tag_beneath_that_device()
|
||||||
|
{
|
||||||
|
var (trie, _) = BuildFromArtifact();
|
||||||
|
|
||||||
|
var matches = trie.CollectMatches(
|
||||||
|
new NodeScope
|
||||||
|
{
|
||||||
|
ClusterId = "c1",
|
||||||
|
Kind = NodeHierarchyKind.Raw,
|
||||||
|
Segments = ["Plant", "Line1", "ModbusDrv", "dev1", "Fast", "Speed"],
|
||||||
|
},
|
||||||
|
["p"]);
|
||||||
|
|
||||||
|
var match = matches.ShouldHaveSingleItem();
|
||||||
|
match.PermissionFlags.ShouldBe(NodePermissions.Read);
|
||||||
|
// Provenance is the authored kind, not the depth the grant happens to sit at.
|
||||||
|
match.Scope.ShouldBe(NodeAclScopeKind.RawDevice);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_tag_under_a_DIFFERENT_device_does_not_match_that_grant()
|
||||||
|
{
|
||||||
|
// The negative control. Without it the test above passes for any trie that returns every grant it
|
||||||
|
// holds regardless of path — which is exactly what a broken walk would do.
|
||||||
|
var (trie, _) = BuildFromArtifact();
|
||||||
|
|
||||||
|
trie.CollectMatches(
|
||||||
|
new NodeScope
|
||||||
|
{
|
||||||
|
ClusterId = "c1",
|
||||||
|
Kind = NodeHierarchyKind.Raw,
|
||||||
|
Segments = ["Plant", "Line1", "ModbusDrv", "otherdev", "Fast", "Speed"],
|
||||||
|
},
|
||||||
|
["p"]).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_same_grant_set_matches_through_the_UNS_realm_too()
|
||||||
|
{
|
||||||
|
// The equipment grant is authored once and must be reachable at the UNS NodeId, which is a
|
||||||
|
// completely different path from the raw one for the same underlying value.
|
||||||
|
var (trie, _) = BuildFromArtifact();
|
||||||
|
|
||||||
|
var matches = trie.CollectMatches(
|
||||||
|
new NodeScope
|
||||||
|
{
|
||||||
|
ClusterId = "c1",
|
||||||
|
Kind = NodeHierarchyKind.Uns,
|
||||||
|
Segments = ["West", "L1", "Mixer", "MixerSpeed"],
|
||||||
|
},
|
||||||
|
["p"]);
|
||||||
|
|
||||||
|
matches.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_raw_scope_does_not_pick_up_a_UNS_grant_at_the_same_depth()
|
||||||
|
{
|
||||||
|
// Realm isolation. "West/L1/Mixer" and a three-segment raw path are the same depth, so without the
|
||||||
|
// realm gate at level 1 a UNS grant would match a raw node whose folder/driver/device happened to
|
||||||
|
// be named the same. The two trees are authored independently, so that collision is not exotic.
|
||||||
|
var (trie, _) = BuildFromArtifact();
|
||||||
|
|
||||||
|
trie.CollectMatches(
|
||||||
|
new NodeScope
|
||||||
|
{
|
||||||
|
ClusterId = "c1",
|
||||||
|
Kind = NodeHierarchyKind.Raw,
|
||||||
|
Segments = ["West", "L1", "Mixer", "MixerSpeed"],
|
||||||
|
},
|
||||||
|
["p"]).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_dangling_scope_is_dropped_with_a_diagnostic_rather_than_planted()
|
||||||
|
{
|
||||||
|
var (_, diagnostics) = BuildFromArtifact();
|
||||||
|
|
||||||
|
var diagnostic = diagnostics.ShouldHaveSingleItem();
|
||||||
|
diagnostic.NodeAclId.ShouldBe("acl-dangling");
|
||||||
|
diagnostic.Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Case_drift_in_a_path_segment_grants_nothing()
|
||||||
|
{
|
||||||
|
// Raw sibling names are unique under an ordinal comparer, so "Speed" and "speed" can both exist
|
||||||
|
// under one device. The trie compared OrdinalIgnoreCase before stage 3, which would have let a
|
||||||
|
// grant on either match both. This goes red if the comparer is reverted.
|
||||||
|
var (trie, _) = BuildFromArtifact();
|
||||||
|
|
||||||
|
trie.CollectMatches(
|
||||||
|
new NodeScope
|
||||||
|
{
|
||||||
|
ClusterId = "c1",
|
||||||
|
Kind = NodeHierarchyKind.Raw,
|
||||||
|
Segments = ["plant", "Line1", "ModbusDrv", "dev1", "Fast", "Speed"],
|
||||||
|
},
|
||||||
|
["p"]).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_artifact_with_no_topology_resolves_no_scopes()
|
||||||
|
{
|
||||||
|
var projection = DeploymentArtifact.ParseDataPlaneAuthority(
|
||||||
|
Encoding.UTF8.GetBytes("""
|
||||||
|
{"NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-x","PermissionFlags":1}]}
|
||||||
|
"""),
|
||||||
|
NodeId);
|
||||||
|
|
||||||
|
projection.Grants.ShouldHaveSingleItem();
|
||||||
|
projection.ScopePaths.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user