Compare commits
7 Commits
phase-2-cl
...
equipment-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bf3938cdf | ||
| 7a42f6d84c | |||
|
|
2b2991c593 | ||
| 9711d0c097 | |||
|
|
1ddc13b7fc | ||
|
|
97e1f55bbb | ||
| cb2a375548 |
248
docs/v2/implementation/adr-001-equipment-node-walker.md
Normal file
248
docs/v2/implementation/adr-001-equipment-node-walker.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# ADR-001 — Equipment node walker: how driver tags bind to the UNS address space
|
||||
|
||||
**Status:** Accepted 2026-04-20 — Option A (Config-primary); Option D deferred to v2.1
|
||||
|
||||
**Related tasks:** [#195 IdentificationFolderBuilder wire-in](../../../) (blocked on this)
|
||||
|
||||
**Related decisions in `plan.md`:** #110 (Tag belongs to Equipment via FK in Equipment ns),
|
||||
#116 / #117 / #121 (five identifiers as properties, `Equipment.Name` as path segment),
|
||||
#120 (UNS hierarchy mandatory in Equipment ns; SystemPlatform ns exempt).
|
||||
|
||||
## Context
|
||||
|
||||
Today the `DriverNodeManager` builds its address space by calling
|
||||
`ITagDiscovery.DiscoverAsync` on each registered driver. Every driver returns whatever
|
||||
browse shape its wire protocol produces — Galaxy returns gobjects with attributes, Modbus
|
||||
returns whatever tag configs the operator authored, AB CIP returns controller-walk output,
|
||||
etc. The result is a per-driver subtree, rooted under the driver's own namespace, with no
|
||||
UNS levels.
|
||||
|
||||
The Config DB meanwhile carries the authoritative UNS model for every Equipment-kind
|
||||
namespace:
|
||||
|
||||
```
|
||||
ConfigGeneration
|
||||
└─ ServerCluster
|
||||
└─ Namespace (Kind=Equipment)
|
||||
└─ UnsArea
|
||||
└─ UnsLine
|
||||
└─ Equipment (carries 9 OPC 40010 Identification fields + 5 identifiers)
|
||||
└─ Tag (EquipmentId FK when Kind=Equipment; DriverInstanceId + FolderPath when Kind=SystemPlatform)
|
||||
```
|
||||
|
||||
Decision #110 already binds `Tag → Equipment` by foreign key. Decision #120 requires the
|
||||
Equipment-namespace browse tree to conform to `Enterprise/Site/Area/Line/Equipment/TagName`.
|
||||
The building blocks exist:
|
||||
|
||||
- `IdentificationFolderBuilder.Build(equipmentBuilder, row)` — pure function that hangs
|
||||
nine OPC 40010 properties under an Equipment node. Shipped, untested in integration.
|
||||
- `Equipment` table rows with `UnsLineId` FK + the 9 identification columns + the 5
|
||||
identifier columns.
|
||||
- `Tag` table rows with nullable `EquipmentId` + a `TagConfig` JSON column carrying the
|
||||
wire-level address.
|
||||
- `NodeScopeResolver` — Phase-1 stub that returns a cluster-level scope only, with an
|
||||
explicit "future resolver will join against the Configuration DB" note.
|
||||
|
||||
What's missing is the **walker**: server-side code that reads the UNS + Equipment + Tag
|
||||
rows for the current published generation, traverses them in UNS order, materializes each
|
||||
level as an OPC UA folder, and wires `IdentificationFolderBuilder.Build` + the 5-identifier
|
||||
properties under each Equipment node.
|
||||
|
||||
The walker isn't pure bookkeeping — it has to decide **how driver-discovered tags bind to
|
||||
UNS Equipment nodes**. That's the decision this ADR resolves.
|
||||
|
||||
## Open question
|
||||
|
||||
> For an Equipment-kind driver, is the published OPC UA surface driven by (a) the Config
|
||||
> DB's `Tag` rows, (b) the driver's `ITagDiscovery.DiscoverAsync` output, or (c) some
|
||||
> combination?
|
||||
|
||||
SystemPlatform-kind drivers (Galaxy only, today) are unambiguous: decision #120 exempts
|
||||
them from UNS + they keep their v1 native hierarchy. The walker does not touch
|
||||
SystemPlatform namespaces beyond the existing driver-discovery path. This ADR only decides
|
||||
Equipment-kind composition.
|
||||
|
||||
## Options
|
||||
|
||||
### Option A — Config-primary
|
||||
|
||||
The `Tag` table is the sole source of truth for what gets published. `ITagDiscovery`
|
||||
becomes a validation + enrichment surface, not a discovery surface.
|
||||
|
||||
**Walker flow:**
|
||||
|
||||
1. Read `UnsArea` / `UnsLine` / `Equipment` / `Tag` for the published generation.
|
||||
2. Walk Area → Line → Equipment, materializing each level as an OPC UA folder.
|
||||
3. Under each Equipment node:
|
||||
- Add the 5 identifier properties (`EquipmentId`, `EquipmentUuid`, `MachineCode`,
|
||||
`ZTag`, `SAPID`) as OPC UA properties per decision #121.
|
||||
- Call `IdentificationFolderBuilder.Build` to add the `Identification` sub-folder with
|
||||
the 9 OPC 40010 fields.
|
||||
- For each `Tag` row bound to this Equipment: ask the driver's `IReadable` /
|
||||
`IWritable` surface whether it can address `Tag.TagConfig.address`; if yes, create a
|
||||
variable node. If no, create a `BadNotFound` placeholder with a diagnostic so
|
||||
operators see the mismatch instead of a silent drop.
|
||||
4. `ITagDiscovery.DiscoverAsync` is re-purposed to **enrich** — driver may return schema
|
||||
hints (data type, bounds, description) that operators missed when authoring the Tag
|
||||
row. The Admin UI surfaces them as "driver suggests" hints for next-draft edits.
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- ✅ Matches decision #110's framing cleanly. `Tag` rows carry the contract; nothing gets
|
||||
published that's not explicitly authored.
|
||||
- ✅ Same model for every Equipment-kind driver. Modbus / S7 / AB CIP / AB Legacy /
|
||||
TwinCAT / FOCAS / OpcUaClient all compose identically.
|
||||
- ✅ UNS hierarchy is always exactly as-authored. No race between "driver added a tag at
|
||||
runtime" and "operator hasn't approved it yet."
|
||||
- ✅ Aligns with the Config-DB-first operator story the Admin UI already tells.
|
||||
- ❌ Drivers with large native schemas (TwinCAT PLCs with thousands of symbols, AB CIP
|
||||
controllers with full @tags walkers) can't "just publish everything" — operators must
|
||||
author Tag rows. This is a pure workflow cost, not a correctness cost.
|
||||
- ❌ A Tag row whose driver can't address it produces a placeholder node at runtime
|
||||
(BadNotFound), not a publish-time validation failure. Mitigation: `sp_ValidateDraft`
|
||||
already validates per-driver references at publish — extend it to call each driver's
|
||||
existence check, or keep it as runtime-visible with an Admin UI indicator.
|
||||
|
||||
### Option B — Discovery-primary
|
||||
|
||||
`ITagDiscovery.DiscoverAsync` is the source of truth for what gets published. The walker
|
||||
joins discovered tags against Config-DB Equipment rows to assemble the UNS tree.
|
||||
|
||||
**Walker flow:**
|
||||
|
||||
1. Driver runs `ITagDiscovery.DiscoverAsync` — returns its native tag graph.
|
||||
2. Walker reads `Equipment` + `Tag` rows; uses `Tag.TagConfig.address` to match against
|
||||
discovered references.
|
||||
3. For each match: materialize the UNS path + attach the discovered variable under the
|
||||
bound Equipment node.
|
||||
4. Discovered tags with no matching `Tag` row: silently dropped (or surfaced under a
|
||||
`Unmapped/` diagnostic folder).
|
||||
5. `Tag` rows with no discovered match: hidden (or surfaced as `BadNotFound` placeholder
|
||||
same as Option A).
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- ✅ Lets drivers with rich discovery (TwinCAT `SymbolLoaderFactory`, AB CIP `@tags`)
|
||||
publish live controller state without operator-authored Tag rows for every symbol.
|
||||
- ✅ Driver-native metadata (real OPC UA data types, real bounds) is authoritative.
|
||||
- ❌ Conflicts with the Config-DB-first publish workflow. Operators publish a generation
|
||||
+ discover a different set at runtime + the two don't necessarily match. Diff tooling
|
||||
becomes harder.
|
||||
- ❌ Galaxy's SystemPlatform-namespace path still uses Option-B-like discovery — so the
|
||||
codebase would host two compositions regardless. But adding a second
|
||||
discovery-primary composition for Equipment-kind would double the surface operators
|
||||
have to reason about.
|
||||
- ❌ Requires each driver to emit tag identifiers that stably match `Tag.TagConfig.address`
|
||||
shape across re-discovery. Works for Galaxy (attribute full refs are stable); harder for
|
||||
AB CIP where the @tags walker may return tags operators haven't declared.
|
||||
- ❌ Operator-visible symptom of "my tag didn't publish" splits between two places: the
|
||||
Tag row exists (Config DB) + the driver can't find it (runtime discovery). Option A
|
||||
surfaces the same gap as a single `BadNotFound` placeholder; B multiplies it.
|
||||
|
||||
### Option C — Parallel namespaces
|
||||
|
||||
Driver tags are always published under a driver-native folder hierarchy (discovery-driven,
|
||||
same as today). A secondary UNS "view" namespace is overlaid, containing Equipment nodes
|
||||
with Identification sub-folders + `Organizes` references pointing at the driver-native tag
|
||||
nodes.
|
||||
|
||||
**Walker flow:**
|
||||
|
||||
1. Driver's native discovery publishes `ns=2;s={DriverInstanceId}/{...driver shape}` as
|
||||
today.
|
||||
2. Walker reads UNS + Equipment + Tag rows.
|
||||
3. For each Equipment, creates a node under the UNS namespace (`ns=3;s=UNS/Site/Area/Line/Equipment`)
|
||||
+ adds Identification properties + creates `Organizes` references from the Equipment
|
||||
node to the matching driver-native variable nodes.
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- ✅ Preserves the discovery-first driver shape — no change to what Modbus / S7 / AB CIP
|
||||
publish natively; those projects keep working identically.
|
||||
- ✅ UNS tree becomes an overlay that operators can opt into or out of. External consumers
|
||||
that want UNS addressing browse via the UNS namespace; consumers that want driver-native
|
||||
addressing keep using the driver namespace.
|
||||
- ❌ Doubles the OPC UA node count for every Equipment-kind tag (one node in driver ns,
|
||||
one reference in UNS ns). OPC UA clients handle it but it inflates browse-result sizes.
|
||||
- ❌ Contradicts decision #120: "Equipment namespace browse paths must conform to the
|
||||
canonical 5-level Unified Namespace structure." Option C makes the driver namespace
|
||||
browse path NOT conform; the UNS namespace is a second view. An external client that
|
||||
reads the Equipment namespace in driver-native shape doesn't see UNS at all.
|
||||
- ❌ Identification ACL semantics get complicated — the sub-folder lives in the UNS ns,
|
||||
but the tag data lives in the driver ns. Two different scope ids; two grants to author.
|
||||
|
||||
### Option D — Config-primary with driver-discovery-assist
|
||||
|
||||
Same as Option A, but `ITagDiscovery.DiscoverAsync` is called during *draft authoring*
|
||||
(not at server runtime) to populate an Admin UI "discovered tags available" panel that
|
||||
operators can one-click-add to the draft Tag table. At publish time the Tag rows drive
|
||||
the server as in Option A — discovery runs only as an offline helper.
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- ✅ Keeps Option A's runtime semantics — Config DB is the sole publish-time truth.
|
||||
- ✅ Closes Option A's only real workflow weakness (authoring Tag rows for large
|
||||
controllers) by letting operators import discovered tags with a click.
|
||||
- ✅ Draws a clean line between author-time discovery (optional, offline) and publish-time
|
||||
resolution (strict, Config-DB-driven).
|
||||
- ❌ Adds work that isn't on the Phase 6.4 checklist — Admin UI needs a "pull discovered
|
||||
tags from this driver" flow, which means the Admin host needs to proxy a DiscoverAsync
|
||||
call through the Server process (or directly into the driver — more complex deployment
|
||||
topology). v2.1 work, not v2.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Pick Option A.** Ship the walker as Config-primary immediately; defer Option D's
|
||||
Admin-UI discovery-assist to v2.1 once the walker is proven.
|
||||
|
||||
Reasons:
|
||||
|
||||
1. **Decision #110 already points here.** `Tag.EquipmentId` + `Tag.TagConfig` are the
|
||||
published contract. Option A is the straight-line implementation of that contract.
|
||||
2. **Identical composition across seven drivers.** Every Equipment-kind driver uses the
|
||||
same walker code path. New drivers (e.g. a future OPC UA Client gateway mode) plug in
|
||||
without touching the walker.
|
||||
3. **Phase 6.4 Admin UI already authors Tag rows.** CSV import, UnsTab drag/drop, draft
|
||||
diff — all operate on Tag rows. The walker being Tag-row-driven means the Admin UI
|
||||
and the server see the same surface.
|
||||
4. **BadNotFound is a clean failure mode.** An operator publishes a Tag row whose
|
||||
address the driver can't reach → client sees a `BadNotFound` variable with a
|
||||
diagnostic, operator fixes the Tag row + republishes. This is legible + easy to
|
||||
debug. Options B and C smear the failure across multiple namespaces.
|
||||
5. **Option D is additive, not alternative.** Nothing in A blocks adding D later; the
|
||||
walker contract stays the same, Admin UI just gets a discovery-assist panel.
|
||||
|
||||
The walker implementation lands under two tasks this ADR spawns (if accepted):
|
||||
|
||||
- **Task A** — Build `EquipmentNodeWalker` in `Core.OpcUa` that drives the
|
||||
`ClusterNode → Namespace → UnsArea → UnsLine → Equipment → Tag` traversal, calls
|
||||
`IdentificationFolderBuilder.Build` per Equipment, materializes the 5 identifier
|
||||
properties, and creates variable nodes for each bound Tag row. Writes integration
|
||||
tests covering the happy path + BadNotFound placeholder.
|
||||
- **Task B** — Extend `NodeScopeResolver` to join against Config DB + populate the
|
||||
full `NodeScope` path (UnsAreaId / UnsLineId / EquipmentId / TagId). Unblocks the
|
||||
Phase 6.2 finer-grained ACL (per-Equipment, per-UnsLine grants). Add ACL integration
|
||||
test per task #195 — browse `Equipment/Identification` as unauthorized user,
|
||||
assert `BadUserAccessDenied`.
|
||||
|
||||
Task #195 closes on Task B's landing.
|
||||
|
||||
## Consequences if we don't decide
|
||||
|
||||
- Task #195 stays blocked. The `IdentificationFolderBuilder` exists but is dead code
|
||||
reachable only from its unit tests.
|
||||
- `NodeScopeResolver` stays at cluster-level scope. Per-Equipment / per-UnsLine ACL
|
||||
grants work at the Admin UI authoring layer + the data-plane evaluator, but the
|
||||
runtime scope resolution never populates anything below `ClusterId + TagId` — so
|
||||
finer grants are effectively cluster-wide at dispatch. Phase 6.2's rollout plan calls
|
||||
this out as a rollout limitation; it's not a correctness bug but it's a feature gap.
|
||||
- Equipment metadata (the 9 OPC 40010 fields, the 5 identifiers) ships in the Config DB
|
||||
+ the Admin UI editor but never surfaces on the OPC UA endpoint. External consumers
|
||||
(ERP, SAP PM) can't resolve equipment via OPC UA properties as decision #121 promises.
|
||||
|
||||
## Next step
|
||||
|
||||
Accept this ADR + spawn Task A + Task B.
|
||||
|
||||
If the recommendation is rejected, the alternative options (B / C / D) are ranked by
|
||||
implementation cost in the Trade-offs sections above. My strong preference is A + defer D.
|
||||
173
src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs
Normal file
173
src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Materializes the canonical Unified Namespace browse tree for an Equipment-kind
|
||||
/// <see cref="Configuration.Entities.Namespace"/> from the Config DB's
|
||||
/// <c>UnsArea</c> / <c>UnsLine</c> / <c>Equipment</c> / <c>Tag</c> rows. Runs during
|
||||
/// address-space build per <see cref="IDriver"/> whose
|
||||
/// <c>Namespace.Kind = Equipment</c>; SystemPlatform-kind namespaces (Galaxy) are
|
||||
/// exempt per decision #120 and reach this walker only indirectly through
|
||||
/// <see cref="ITagDiscovery.DiscoverAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Composition strategy.</b> ADR-001 (2026-04-20) accepted Option A — Config
|
||||
/// primary. The walker treats the supplied <see cref="EquipmentNamespaceContent"/>
|
||||
/// snapshot as the authoritative published surface. Every Equipment row becomes a
|
||||
/// folder node at the UNS level-5 segment; every <see cref="Tag"/> bound to an
|
||||
/// Equipment (non-null <see cref="Tag.EquipmentId"/>) becomes a variable node under
|
||||
/// it. Driver-discovered tags that have no Config-DB row are not added by this
|
||||
/// walker — the ITagDiscovery path continues to exist for the SystemPlatform case +
|
||||
/// for enrichment, but Equipment-kind composition is fully Tag-row-driven.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Under each Equipment node.</b> Five identifier properties per decision #121
|
||||
/// (<c>EquipmentId</c>, <c>EquipmentUuid</c>, <c>MachineCode</c>, <c>ZTag</c>,
|
||||
/// <c>SAPID</c>) are added as OPC UA properties — external systems (ERP, SAP PM)
|
||||
/// resolve equipment by whichever identifier they natively use without a sidecar.
|
||||
/// <see cref="IdentificationFolderBuilder.Build"/> materializes the OPC 40010
|
||||
/// Identification sub-folder with the nine decision-#139 fields when at least one
|
||||
/// is non-null; when all nine are null the sub-folder is omitted rather than
|
||||
/// appearing empty.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Address resolution.</b> Variable nodes carry the driver-side full reference
|
||||
/// in <see cref="DriverAttributeInfo.FullName"/> copied from <c>Tag.TagConfig</c>
|
||||
/// (the wire-level address JSON blob whose interpretation is driver-specific). At
|
||||
/// runtime the dispatch layer routes Read/Write calls through the configured
|
||||
/// capability invoker; an unreachable address surfaces as an OPC UA Bad status via
|
||||
/// the natural driver-read failure path, NOT as a build-time reject. The ADR calls
|
||||
/// this "BadNotFound placeholder" behavior — legible to operators via their Admin
|
||||
/// UI + OPC UA client inspection of node status.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Pure function.</b> This class has no dependency on the OPC UA SDK, no
|
||||
/// Config-DB access, no state. It consumes pre-loaded EF Core rows + streams calls
|
||||
/// into the supplied <see cref="IAddressSpaceBuilder"/>. The server-side wiring
|
||||
/// (load snapshot → invoke walker → per-tag capability probe) lives in the Task B
|
||||
/// PR alongside <c>NodeScopeResolver</c>'s Config-DB join.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class EquipmentNodeWalker
|
||||
{
|
||||
/// <summary>
|
||||
/// Walk <paramref name="content"/> into <paramref name="namespaceBuilder"/>.
|
||||
/// The builder is scoped to the Equipment-kind namespace root; the walker emits
|
||||
/// Area → Line → Equipment folders under it, then identifier properties + the
|
||||
/// Identification sub-folder + variable nodes per bound Tag under each Equipment.
|
||||
/// </summary>
|
||||
/// <param name="namespaceBuilder">
|
||||
/// The builder scoped to the Equipment-kind namespace root. Caller is responsible for
|
||||
/// creating this (e.g. <c>rootBuilder.Folder(namespace.NamespaceId, namespace.NamespaceUri)</c>).
|
||||
/// </param>
|
||||
/// <param name="content">Pre-loaded + pre-filtered rows for a single published generation.</param>
|
||||
public static void Walk(IAddressSpaceBuilder namespaceBuilder, EquipmentNamespaceContent content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(namespaceBuilder);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
// Group lines by area + equipment by line + tags by equipment up-front. Avoids an
|
||||
// O(N·M) re-scan at each UNS level on large fleets.
|
||||
var linesByArea = content.Lines
|
||||
.GroupBy(l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(l => l.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var equipmentByLine = content.Equipment
|
||||
.GroupBy(e => e.UnsLineId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tagsByEquipment = content.Tags
|
||||
.Where(t => !string.IsNullOrEmpty(t.EquipmentId))
|
||||
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
|
||||
if (!linesByArea.TryGetValue(area.UnsAreaId, out var areaLines)) continue;
|
||||
|
||||
foreach (var line in areaLines)
|
||||
{
|
||||
var lineBuilder = areaBuilder.Folder(line.Name, line.Name);
|
||||
if (!equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment)) continue;
|
||||
|
||||
foreach (var equipment in lineEquipment)
|
||||
{
|
||||
var equipmentBuilder = lineBuilder.Folder(equipment.Name, equipment.Name);
|
||||
AddIdentifierProperties(equipmentBuilder, equipment);
|
||||
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
|
||||
|
||||
if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue;
|
||||
foreach (var tag in equipmentTags)
|
||||
AddTagVariable(equipmentBuilder, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the five operator-facing identifiers from decision #121 as OPC UA properties
|
||||
/// on the Equipment node. EquipmentId + EquipmentUuid are always populated;
|
||||
/// MachineCode is required per <see cref="Equipment"/>; ZTag + SAPID are nullable in
|
||||
/// the data model so they're skipped when null to avoid empty-string noise in the
|
||||
/// browse tree.
|
||||
/// </summary>
|
||||
private static void AddIdentifierProperties(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
|
||||
{
|
||||
equipmentBuilder.AddProperty("EquipmentId", DriverDataType.String, equipment.EquipmentId);
|
||||
equipmentBuilder.AddProperty("EquipmentUuid", DriverDataType.String, equipment.EquipmentUuid.ToString());
|
||||
equipmentBuilder.AddProperty("MachineCode", DriverDataType.String, equipment.MachineCode);
|
||||
if (!string.IsNullOrEmpty(equipment.ZTag))
|
||||
equipmentBuilder.AddProperty("ZTag", DriverDataType.String, equipment.ZTag);
|
||||
if (!string.IsNullOrEmpty(equipment.SAPID))
|
||||
equipmentBuilder.AddProperty("SAPID", DriverDataType.String, equipment.SAPID);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit a single Tag row as an <see cref="IAddressSpaceBuilder.Variable"/>. The driver
|
||||
/// full reference lives in <c>Tag.TagConfig</c> (wire-level address, driver-specific
|
||||
/// JSON blob); the variable node's data type derives from <c>Tag.DataType</c>.
|
||||
/// Unreachable-address behavior per ADR-001 Option A: the variable is created; the
|
||||
/// driver's natural Read failure surfaces an OPC UA Bad status at runtime.
|
||||
/// </summary>
|
||||
private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag)
|
||||
{
|
||||
var attr = new DriverAttributeInfo(
|
||||
FullName: tag.TagConfig,
|
||||
DriverDataType: ParseDriverDataType(tag.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.FreeAccess,
|
||||
IsHistorized: false);
|
||||
equipmentBuilder.Variable(tag.Name, tag.Name, attr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse <see cref="Tag.DataType"/> (stored as the <see cref="DriverDataType"/> enum
|
||||
/// name string, decision #138) into the enum value. Unknown names fall back to
|
||||
/// <see cref="DriverDataType.String"/> so a one-off driver-specific type doesn't
|
||||
/// abort the whole walk; the underlying driver still sees the original TagConfig
|
||||
/// address + can surface its own typed value via the OPC UA variant at read time.
|
||||
/// </summary>
|
||||
private static DriverDataType ParseDriverDataType(string raw) =>
|
||||
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config
|
||||
/// DB rows. All four collections are scoped to the same
|
||||
/// <see cref="Configuration.Entities.ConfigGeneration"/> + the same
|
||||
/// <see cref="Configuration.Entities.Namespace"/> row. The walker assumes this filter
|
||||
/// was applied by the caller + does no cross-generation or cross-namespace validation.
|
||||
/// </summary>
|
||||
public sealed record EquipmentNamespaceContent(
|
||||
IReadOnlyList<UnsArea> Areas,
|
||||
IReadOnlyList<UnsLine> Lines,
|
||||
IReadOnlyList<Equipment> Equipment,
|
||||
IReadOnlyList<Tag> Tags);
|
||||
@@ -1,42 +1,83 @@
|
||||
using System.Collections.Frozen;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a driver-side full reference (e.g. <c>"TestMachine_001/Oven/SetPoint"</c>) to the
|
||||
/// <see cref="NodeScope"/> the Phase 6.2 evaluator walks. Today a simplified resolver that
|
||||
/// returns a cluster-scoped + tag-only scope — the deeper UnsArea / UnsLine / Equipment
|
||||
/// path lookup from the live Configuration DB is a Stream C.12 follow-up.
|
||||
/// <see cref="NodeScope"/> the Phase 6.2 evaluator walks. Supports two modes:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>Cluster-only (pre-ADR-001)</b> — when no path index is supplied the resolver
|
||||
/// returns a flat <c>ClusterId + TagId</c> scope. Sufficient while the
|
||||
/// Config-DB-driven Equipment walker isn't live; Cluster-level grants cascade to every
|
||||
/// tag below per decision #129, so finer per-Equipment grants are effectively
|
||||
/// cluster-wide at dispatch.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Full-path (post-ADR-001 Task B)</b> — when an index is supplied, the resolver
|
||||
/// joins the full reference against the index to produce a complete
|
||||
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> scope. Unblocks
|
||||
/// per-Equipment / per-UnsLine ACL grants at the dispatch layer.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The flat cluster-level scope is sufficient for v2 GA because Phase 6.2 ACL grants
|
||||
/// at the Cluster scope cascade to every tag below (decision #129 — additive grants). The
|
||||
/// finer hierarchy only matters when operators want per-area or per-equipment grants;
|
||||
/// those still work for Cluster-level grants, and landing the finer resolution in a
|
||||
/// follow-up doesn't regress the base security model.</para>
|
||||
/// <para>The index is pre-loaded by the Server bootstrap against the published generation;
|
||||
/// the resolver itself does no live DB access. Resolve is O(1) dictionary lookup on the
|
||||
/// hot path; the fallback for unknown fullReference strings produces the same cluster-only
|
||||
/// scope the pre-ADR-001 resolver returned — new tags picked up via driver discovery but
|
||||
/// not yet indexed (e.g. between a DiscoverAsync result and the next generation publish)
|
||||
/// stay addressable without a scope-resolver crash.</para>
|
||||
///
|
||||
/// <para>Thread-safety: the resolver is stateless once constructed. Callers may cache a
|
||||
/// single instance per DriverNodeManager without locks.</para>
|
||||
/// <para>Thread-safety: both constructor paths freeze inputs into immutable state. Callers
|
||||
/// may cache a single instance per DriverNodeManager without locks. Swap atomically on
|
||||
/// generation change via the server's publish pipeline.</para>
|
||||
/// </remarks>
|
||||
public sealed class NodeScopeResolver
|
||||
{
|
||||
private readonly string _clusterId;
|
||||
private readonly FrozenDictionary<string, NodeScope>? _index;
|
||||
|
||||
/// <summary>Cluster-only resolver — pre-ADR-001 behavior. Kept for Server processes that
|
||||
/// haven't wired the Config-DB snapshot flow yet.</summary>
|
||||
public NodeScopeResolver(string clusterId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
_clusterId = clusterId;
|
||||
_index = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full-path resolver (ADR-001 Task B). <paramref name="pathIndex"/> maps each known
|
||||
/// driver-side full reference to its pre-resolved <see cref="NodeScope"/> carrying
|
||||
/// every UNS level populated. Entries are typically produced by joining
|
||||
/// <c>Tag → Equipment → UnsLine → UnsArea</c> rows of the published generation against
|
||||
/// the driver's discovered full references (or against <c>Tag.TagConfig</c> directly
|
||||
/// when the walker is config-primary per ADR-001 Option A).
|
||||
/// </summary>
|
||||
public NodeScopeResolver(string clusterId, IReadOnlyDictionary<string, NodeScope> pathIndex)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentNullException.ThrowIfNull(pathIndex);
|
||||
_clusterId = clusterId;
|
||||
_index = pathIndex.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a node scope for the given driver-side <paramref name="fullReference"/>.
|
||||
/// Phase 1 shape: returns <c>ClusterId</c> + <c>TagId = fullReference</c> only;
|
||||
/// NamespaceId / UnsArea / UnsLine / Equipment stay null. A future resolver will
|
||||
/// join against the Configuration DB to populate the full path.
|
||||
/// Returns the indexed full-path scope when available; falls back to cluster-only
|
||||
/// (TagId populated only) when the index is absent or the reference isn't indexed.
|
||||
/// The fallback is the same shape the pre-ADR-001 resolver produced, so the authz
|
||||
/// evaluator behaves identically for un-indexed references.
|
||||
/// </summary>
|
||||
public NodeScope Resolve(string fullReference)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fullReference);
|
||||
|
||||
if (_index is not null && _index.TryGetValue(fullReference, out var indexed))
|
||||
return indexed;
|
||||
|
||||
return new NodeScope
|
||||
{
|
||||
ClusterId = _clusterId,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="NodeScope"/> path index consumed by <see cref="NodeScopeResolver"/>
|
||||
/// from a Config-DB snapshot of a single published generation. Runs once per generation
|
||||
/// (or on every generation change) at the Server bootstrap layer; the produced index is
|
||||
/// immutable + hot-path readable per ADR-001 Task B.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The index key is the driver-side full reference (<c>Tag.TagConfig</c>) — the same
|
||||
/// string the dispatch layer passes to <see cref="NodeScopeResolver.Resolve"/>. The value
|
||||
/// is a <see cref="NodeScope"/> with every UNS level populated:
|
||||
/// <c>ClusterId / NamespaceId / UnsAreaId / UnsLineId / EquipmentId / TagId</c>. Tag rows
|
||||
/// with null <c>EquipmentId</c> (SystemPlatform-namespace Galaxy tags per decision #120)
|
||||
/// are excluded from the index — the cluster-only fallback path in the resolver handles
|
||||
/// them without needing an index entry.</para>
|
||||
///
|
||||
/// <para>Duplicate keys are not expected but would be indicative of corrupt data — the
|
||||
/// builder throws <see cref="InvalidOperationException"/> on collision so a config drift
|
||||
/// surfaces at bootstrap instead of producing silently-last-wins scopes at dispatch.</para>
|
||||
/// </remarks>
|
||||
public static class ScopePathIndexBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a fullReference → NodeScope index from the four Config-DB collections for a
|
||||
/// single namespace. Callers must filter inputs to a single
|
||||
/// <see cref="Namespace"/> + the same <see cref="ConfigGeneration"/> upstream.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">Owning cluster — populates <see cref="NodeScope.ClusterId"/>.</param>
|
||||
/// <param name="namespaceId">Owning namespace — populates <see cref="NodeScope.NamespaceId"/>.</param>
|
||||
/// <param name="content">Pre-loaded rows for the namespace.</param>
|
||||
public static IReadOnlyDictionary<string, NodeScope> Build(
|
||||
string clusterId,
|
||||
string namespaceId,
|
||||
EquipmentNamespaceContent content)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(namespaceId);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var areaByLine = content.Lines.ToDictionary(l => l.UnsLineId, l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase);
|
||||
var lineByEquipment = content.Equipment.ToDictionary(e => e.EquipmentId, e => e.UnsLineId, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var index = new Dictionary<string, NodeScope>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var tag in content.Tags)
|
||||
{
|
||||
// Null EquipmentId = SystemPlatform-namespace tag per decision #110 — skip; the
|
||||
// cluster-only resolver fallback handles those without needing an index entry.
|
||||
if (string.IsNullOrEmpty(tag.EquipmentId)) continue;
|
||||
|
||||
// Broken FK — Tag references a missing Equipment row. Skip rather than crash;
|
||||
// sp_ValidateDraft should have caught this at publish, so any drift here is
|
||||
// unexpected but non-fatal.
|
||||
if (!lineByEquipment.TryGetValue(tag.EquipmentId, out var lineId)) continue;
|
||||
if (!areaByLine.TryGetValue(lineId, out var areaId)) continue;
|
||||
|
||||
var scope = new NodeScope
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
NamespaceId = namespaceId,
|
||||
UnsAreaId = areaId,
|
||||
UnsLineId = lineId,
|
||||
EquipmentId = tag.EquipmentId,
|
||||
TagId = tag.TagConfig,
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
if (!index.TryAdd(tag.TagConfig, scope))
|
||||
throw new InvalidOperationException(
|
||||
$"Duplicate fullReference '{tag.TagConfig}' in Equipment namespace '{namespaceId}'. " +
|
||||
"Config data is corrupt — two Tag rows produced the same wire-level address.");
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentNodeWalkerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Walk_EmptyContent_EmitsNothing()
|
||||
{
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [], []));
|
||||
|
||||
rec.Children.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder()
|
||||
{
|
||||
var content = new EquipmentNamespaceContent(
|
||||
Areas: [Area("area-1", "warsaw"), Area("area-2", "berlin")],
|
||||
Lines: [Line("line-1", "area-1", "oven-line"), Line("line-2", "area-2", "press-line")],
|
||||
Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")],
|
||||
Tags: []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
rec.Children.Select(c => c.BrowseName).ShouldBe(["berlin", "warsaw"]); // ordered by Name
|
||||
var warsaw = rec.Children.First(c => c.BrowseName == "warsaw");
|
||||
warsaw.Children.Select(c => c.BrowseName).ShouldBe(["oven-line"]);
|
||||
warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid()
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
eq.EquipmentUuid = uuid;
|
||||
eq.MachineCode = "MC-42";
|
||||
eq.ZTag = null;
|
||||
eq.SAPID = null;
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
var props = equipmentNode.Properties.Select(p => p.BrowseName).ToList();
|
||||
props.ShouldContain("EquipmentId");
|
||||
props.ShouldContain("EquipmentUuid");
|
||||
props.ShouldContain("MachineCode");
|
||||
props.ShouldNotContain("ZTag");
|
||||
props.ShouldNotContain("SAPID");
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Adds_ZTag_And_SAPID_When_Present()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
eq.ZTag = "ZT-0042";
|
||||
eq.SAPID = "10000042";
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "ZTag").Value.ShouldBe("ZT-0042");
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
eq.Manufacturer = "Trumpf";
|
||||
eq.Model = "TruLaser-3030";
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
var identification = equipmentNode.Children.FirstOrDefault(c => c.BrowseName == "Identification");
|
||||
identification.ShouldNotBeNull();
|
||||
identification!.Properties.Select(p => p.BrowseName).ShouldContain("Manufacturer");
|
||||
identification.Properties.Select(p => p.BrowseName).ShouldContain("Model");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3"); // no identification fields
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var tag1 = NewTag("tag-1", "Temperature", "Int32", "plcaddr-01", equipmentId: "eq-1");
|
||||
var tag2 = NewTag("tag-2", "Setpoint", "Float32", "plcaddr-02", equipmentId: "eq-1");
|
||||
var unboundTag = NewTag("tag-3", "Orphan", "Int32", "plcaddr-03", equipmentId: null); // SystemPlatform-style, walker skips
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [tag1, tag2, unboundTag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
equipmentNode.Variables.Count.ShouldBe(2);
|
||||
equipmentNode.Variables.Select(v => v.BrowseName).ShouldBe(["Setpoint", "Temperature"]);
|
||||
equipmentNode.Variables.First(v => v.BrowseName == "Temperature").AttributeInfo.FullName.ShouldBe("plcaddr-01");
|
||||
equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_FallsBack_To_String_For_Unparseable_DataType()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var tag = NewTag("tag-1", "Mystery", "NotARealType", "plcaddr-42", equipmentId: "eq-1");
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var variable = rec.Children[0].Children[0].Children[0].Variables.Single();
|
||||
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
// ----- builders for test seed rows -----
|
||||
|
||||
private static UnsArea Area(string id, string name) => new()
|
||||
{
|
||||
UnsAreaId = id, ClusterId = "c1", Name = name, GenerationId = 1,
|
||||
};
|
||||
|
||||
private static UnsLine Line(string id, string areaId, string name) => new()
|
||||
{
|
||||
UnsLineId = id, UnsAreaId = areaId, Name = name, GenerationId = 1,
|
||||
};
|
||||
|
||||
private static Equipment Eq(string equipmentId, string lineId, string name) => new()
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(),
|
||||
GenerationId = 1,
|
||||
EquipmentId = equipmentId,
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = "drv",
|
||||
UnsLineId = lineId,
|
||||
Name = name,
|
||||
MachineCode = "MC-" + name,
|
||||
};
|
||||
|
||||
private static Tag NewTag(string tagId, string name, string dataType, string address, string? equipmentId) => new()
|
||||
{
|
||||
TagRowId = Guid.NewGuid(),
|
||||
GenerationId = 1,
|
||||
TagId = tagId,
|
||||
DriverInstanceId = "drv",
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = dataType,
|
||||
AccessLevel = ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite,
|
||||
TagConfig = address,
|
||||
};
|
||||
|
||||
// ----- recording IAddressSpaceBuilder -----
|
||||
|
||||
private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder
|
||||
{
|
||||
public string BrowseName { get; } = browseName;
|
||||
public List<RecordingBuilder> Children { get; } = new();
|
||||
public List<RecordingVariable> Variables { get; } = new();
|
||||
public List<RecordingProperty> Properties { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string name, string _)
|
||||
{
|
||||
var child = new RecordingBuilder(name);
|
||||
Children.Add(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr)
|
||||
{
|
||||
var v = new RecordingVariable(name, attr);
|
||||
Variables.Add(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
public void AddProperty(string name, DriverDataType _, object? value) =>
|
||||
Properties.Add(new RecordingProperty(name, value));
|
||||
}
|
||||
|
||||
private sealed record RecordingProperty(string BrowseName, object? Value);
|
||||
|
||||
private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle
|
||||
{
|
||||
public string FullReference => AttributeInfo.FullName;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end authz regression test for the ADR-001 Task B close-out of task #195.
|
||||
/// Walks the full dispatch flow for a read against an Equipment / Identification
|
||||
/// property: ScopePathIndexBuilder → NodeScopeResolver → AuthorizationGate → PermissionTrie.
|
||||
/// Proves the contract the IdentificationFolderBuilder docstring promises — a user
|
||||
/// without the Equipment-scope grant gets denied on the Identification sub-folder the
|
||||
/// same way they would be denied on the Equipment node itself, because they share the
|
||||
/// Equipment ScopeId (no new scope level for Identification per the builder's remark
|
||||
/// section).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentIdentificationAuthzTests
|
||||
{
|
||||
private const string Cluster = "c-warsaw";
|
||||
private const string Namespace = "ns-plc";
|
||||
|
||||
[Fact]
|
||||
public void Authorized_Group_Read_Granted_On_Identification_Property()
|
||||
{
|
||||
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
||||
var scope = resolver.Resolve("plcaddr-manufacturer");
|
||||
|
||||
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unauthorized_Group_Read_Denied_On_Identification_Property()
|
||||
{
|
||||
// The contract in task #195 + the IdentificationFolderBuilder docstring: "a user
|
||||
// without the grant gets BadUserAccessDenied on both the Equipment node + its
|
||||
// Identification variables." This test proves the evaluator side of that contract;
|
||||
// the BadUserAccessDenied surfacing happens in the DriverNodeManager dispatch that
|
||||
// already wires AuthorizationGate.IsAllowed → StatusCodes.BadUserAccessDenied.
|
||||
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
||||
var scope = resolver.Resolve("plcaddr-manufacturer");
|
||||
|
||||
var identity = new FakeIdentity("bob", ["cn=other-team"]);
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equipment_Grant_Cascades_To_Its_Identification_Properties()
|
||||
{
|
||||
// Identification properties share their parent Equipment's ScopeId (no new scope
|
||||
// level). An Equipment-scope grant must therefore read both — the Equipment's tag
|
||||
// AND its Identification properties — via the same evaluator call path.
|
||||
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
||||
|
||||
var tagScope = resolver.Resolve("plcaddr-temperature");
|
||||
var identityScope = resolver.Resolve("plcaddr-manufacturer");
|
||||
|
||||
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, tagScope).ShouldBeTrue();
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, identityScope).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_Equipment_Grant_Does_Not_Leak_Across_Equipment_Boundary()
|
||||
{
|
||||
// Grant on oven-3; test reading a tag on press-7 (different equipment). Must deny
|
||||
// so per-Equipment isolation holds at the dispatch layer — the ADR-001 Task B
|
||||
// motivation for populating the full UNS path at resolve time.
|
||||
var (gate, resolver) = BuildEvaluator(
|
||||
equipmentGrantGroup: "cn=oven-3-operators",
|
||||
equipmentIdForGrant: "eq-oven-3");
|
||||
|
||||
var pressScope = resolver.Resolve("plcaddr-press-7-temp"); // belongs to eq-press-7
|
||||
|
||||
var identity = new FakeIdentity("charlie", ["cn=oven-3-operators"]);
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, pressScope).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- harness -----
|
||||
|
||||
/// <summary>
|
||||
/// Build the AuthorizationGate + NodeScopeResolver pair for a fixture with two
|
||||
/// Equipment rows (oven-3 + press-7) under one UNS line, one NodeAcl grant at
|
||||
/// Equipment scope for <paramref name="equipmentGrantGroup"/>, and a ScopePathIndex
|
||||
/// populated via ScopePathIndexBuilder from the same Config-DB row set the
|
||||
/// EquipmentNodeWalker would consume at address-space build.
|
||||
/// </summary>
|
||||
private static (AuthorizationGate Gate, NodeScopeResolver Resolver) BuildEvaluator(
|
||||
string equipmentGrantGroup,
|
||||
string equipmentIdForGrant = "eq-oven-3")
|
||||
{
|
||||
var (content, scopeIndex) = BuildFixture();
|
||||
var resolver = new NodeScopeResolver(Cluster, scopeIndex);
|
||||
|
||||
var aclRow = new NodeAcl
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = Guid.NewGuid().ToString(),
|
||||
GenerationId = 1,
|
||||
ClusterId = Cluster,
|
||||
LdapGroup = equipmentGrantGroup,
|
||||
ScopeKind = NodeAclScopeKind.Equipment,
|
||||
ScopeId = equipmentIdForGrant,
|
||||
PermissionFlags = NodePermissions.Browse | NodePermissions.Read,
|
||||
};
|
||||
var paths = new Dictionary<string, NodeAclPath>
|
||||
{
|
||||
[equipmentIdForGrant] = new NodeAclPath(new[] { Namespace, "area-1", "line-a", equipmentIdForGrant }),
|
||||
};
|
||||
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(PermissionTrieBuilder.Build(Cluster, 1, [aclRow], paths));
|
||||
var evaluator = new TriePermissionEvaluator(cache);
|
||||
var gate = new AuthorizationGate(evaluator, strictMode: true);
|
||||
|
||||
_ = content;
|
||||
return (gate, resolver);
|
||||
}
|
||||
|
||||
private static (EquipmentNamespaceContent, IReadOnlyDictionary<string, NodeScope>) BuildFixture()
|
||||
{
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = Cluster, Name = "warsaw", GenerationId = 1 };
|
||||
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
|
||||
|
||||
var oven = new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "oven-3",
|
||||
MachineCode = "MC-oven-3", Manufacturer = "Trumpf",
|
||||
};
|
||||
var press = new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "press-7",
|
||||
MachineCode = "MC-press-7",
|
||||
};
|
||||
|
||||
// Two tags for oven-3, one for press-7. Use Tag.TagConfig as the driver-side full
|
||||
// reference the dispatch layer passes to NodeScopeResolver.Resolve.
|
||||
var tempTag = NewTag("tag-1", "Temperature", "Int32", "plcaddr-temperature", "eq-oven-3");
|
||||
var mfgTag = NewTag("tag-2", "Manufacturer", "String", "plcaddr-manufacturer", "eq-oven-3");
|
||||
var pressTempTag = NewTag("tag-3", "PressTemp", "Int32", "plcaddr-press-7-temp", "eq-press-7");
|
||||
|
||||
var content = new EquipmentNamespaceContent(
|
||||
Areas: [area],
|
||||
Lines: [line],
|
||||
Equipment: [oven, press],
|
||||
Tags: [tempTag, mfgTag, pressTempTag]);
|
||||
|
||||
var index = ScopePathIndexBuilder.Build(Cluster, Namespace, content);
|
||||
return (content, index);
|
||||
}
|
||||
|
||||
private static Tag NewTag(string tagId, string name, string dataType, string address, string equipmentId) => new()
|
||||
{
|
||||
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = tagId,
|
||||
DriverInstanceId = "drv", EquipmentId = equipmentId, Name = name,
|
||||
DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite, TagConfig = address,
|
||||
};
|
||||
|
||||
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
|
||||
{
|
||||
public FakeIdentity(string name, IReadOnlyList<string> groups)
|
||||
{
|
||||
DisplayName = name;
|
||||
LdapGroups = groups;
|
||||
}
|
||||
public new string DisplayName { get; }
|
||||
public IReadOnlyList<string> LdapGroups { get; }
|
||||
}
|
||||
}
|
||||
@@ -21,19 +21,59 @@ public sealed class NodeScopeResolverTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Leaves_UnsPath_Null_For_Phase1()
|
||||
public void Resolve_Leaves_UnsPath_Null_When_NoIndexSupplied()
|
||||
{
|
||||
var resolver = new NodeScopeResolver("c-1");
|
||||
|
||||
var scope = resolver.Resolve("tag-1");
|
||||
|
||||
// Phase 1 flat scope — finer resolution tracked as Stream C.12 follow-up.
|
||||
// Cluster-only fallback path — used pre-ADR-001 and still the active path for
|
||||
// unindexed references (e.g. driver-discovered tags that have no Tag row yet).
|
||||
scope.NamespaceId.ShouldBeNull();
|
||||
scope.UnsAreaId.ShouldBeNull();
|
||||
scope.UnsLineId.ShouldBeNull();
|
||||
scope.EquipmentId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Returns_IndexedScope_When_FullReferenceFound()
|
||||
{
|
||||
var index = new Dictionary<string, NodeScope>
|
||||
{
|
||||
["plcaddr-01"] = new NodeScope
|
||||
{
|
||||
ClusterId = "c-1", NamespaceId = "ns-plc", UnsAreaId = "area-1",
|
||||
UnsLineId = "line-a", EquipmentId = "eq-oven-3", TagId = "plcaddr-01",
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
},
|
||||
};
|
||||
var resolver = new NodeScopeResolver("c-1", index);
|
||||
|
||||
var scope = resolver.Resolve("plcaddr-01");
|
||||
|
||||
scope.UnsAreaId.ShouldBe("area-1");
|
||||
scope.UnsLineId.ShouldBe("line-a");
|
||||
scope.EquipmentId.ShouldBe("eq-oven-3");
|
||||
scope.TagId.ShouldBe("plcaddr-01");
|
||||
scope.NamespaceId.ShouldBe("ns-plc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_FallsBack_To_ClusterOnly_When_Reference_NotIndexed()
|
||||
{
|
||||
var index = new Dictionary<string, NodeScope>
|
||||
{
|
||||
["plcaddr-01"] = new NodeScope { ClusterId = "c-1", TagId = "plcaddr-01", Kind = NodeHierarchyKind.Equipment },
|
||||
};
|
||||
var resolver = new NodeScopeResolver("c-1", index);
|
||||
|
||||
var scope = resolver.Resolve("not-in-index");
|
||||
|
||||
scope.ClusterId.ShouldBe("c-1");
|
||||
scope.TagId.ShouldBe("not-in-index");
|
||||
scope.EquipmentId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Throws_OnEmptyFullReference()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user