merge: ACL scope picker + authoring-time resolution (#520 stage 7)
v2-ci / build (push) Successful in 4m22s
v2-ci / unit-tests (push) Failing after 14m57s

Closes #520 — all eight stages done and live-gated.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 10:53:37 -04:00
14 changed files with 1703 additions and 97 deletions
+16 -1
View File
@@ -678,7 +678,22 @@ Two things to know before relying on any of this:
an unregistered service, so both must be registered explicitly on every node (deny-all / empty on a
node with neither role) or it does not boot.
Remaining stages, and the rationale for every decision above, are in
**Grants are authored through a scope picker, not free text (stage 7).** `/clusters/{id}/acls` opens
`AclScopePickerModal` over the cluster's raw + UNS trees, and selecting a node supplies `ScopeKind`
**and** `ScopeId` together — so `RawDevice` + a TagId, a cross-cluster scope, a `Cluster` grant carrying
an id, and the retired `FolderSegment` kind are all unauthorable by construction rather than merely
rejected later. ⚠️ **A `Cluster` row must produce a NULL `ScopeId`** — the deploy gate rejects one that
carries an id (`NodeAclScopeIdUnexpected`), so a picker that helpfully filled in the cluster id would
author rows that cannot be deployed. `IAclScopeService.CheckScopeAsync` mirrors
`DraftValidator.ValidateNodeAclScopes` rule for rule and **blocks the save** when a scope resolves to
nothing (re-checked on submit, since a row loaded unchanged is exactly where the operator never touches
the picker); the list page marks such rows `unresolved`. **UNS tags are deliberately not offered** — a
UNS signal is a `UnsTagReference`, and a `Tag` grant already covers the raw node *and* every UNS
projection of it. The picker is `AclScopeTree`, a separate component from `RawTree`, which already
carries two modes.
**All eight stages are done** (`d4389f57` enforcement, stage 7 authoring after it), each live-gated on
docker-dev. The rationale for every decision above is in
`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. See `docs/security.md` for the full guide.
Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`, plaintext/`Transport=None`). It is managed via `scadaproj/infra/glauth/` (source of truth + deploy runbook). Single bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`. The docker-dev compose binds this shared instance directly — `DevStubMode` is no longer used. The per-VM NSSM GLAuth at `C:\publish\glauth\` and the old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. (The integration-test harness under `tests/.../Host.IntegrationTests/` runs its own ephemeral **GLAuth** container on port 3894 — seeded by `tests/.../Host.IntegrationTests/glauth/config.toml` with dev users alice/bob + the `cn=serviceaccount` bind account — for the opt-in `OTOPCUA_HARNESS_USE_LDAP=1` real-bind mode; distinct from the shared dev GLAuth on 3893. It replaced the retired `bitnami/openldap:2.6` in PR #451, 2026-07-15.)
@@ -108,7 +108,10 @@ mismatch).
`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.
- `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.~~ **Closed** —
stage 3 gave it a deploy-time existence check, and stage 7 an authoring-time one plus a picker that
cannot produce a dangling id. There is still no FK: `ScopeId` names a row in one of eight tables
chosen by `ScopeKind`, which is not a foreign key any schema can express.
- ~~`ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference `Core` at all~~ — **stale**; stage 1 added
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.
@@ -169,8 +172,8 @@ Each stage is one commit, builds clean, and leaves the tree shippable.
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.
`FolderSegment` kind, and undefined ordinals. That gate substituted for the ACL scope picker until
stage 7 shipped one; it remains the backstop for rows edited outside the AdminUI.
4. **Artifact + lifecycle.** ✅ **Done.** `ConfigComposer` snapshots `ApiKeys` alongside the `NodeAcls`
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
@@ -271,8 +274,37 @@ Each stage is one commit, builds clean, and leaves the tree shippable.
the moment anything injects a test clock — at which point the evaluator denies *everything* with a
verdict indistinguishable from "no grant matched". Stage 5's claim that these predicates "can never
fire" was accidentally true, not true by construction; they are now disabled explicitly.
7. **AdminUI.** Key management (create/revoke, show-once) ✅ **done early, in stage 4's predecessor**; ACL authoring against principals, and
**remove the two "not enforced" warning banners** — which is the honest signal that this is done.
7. **AdminUI.** ✅ **Done.** Key management (create/revoke, show-once) landed early, in stage 4's
predecessor. This stage added the **scope picker** (`AclScopePickerModal` over the cluster's raw and
UNS trees, backed by `IAclScopeService`), authoring-time **scope and principal resolution**, and
reshaped the two banners.
Four things went differently from this line, all deliberate:
- **The banners were reshaped, not removed.** "Remove the two warning banners" assumed the only thing
they said was *not enforced*, which stage 6 falsified. What remains true is that enforcement is a
**per-node** flag the AdminUI cannot see, so an `Equipment`-scoped grant still confers nothing on a
default-configured node — deleting the banner would have replaced one wrong impression with
another. They are now `alert-info` describing both modes, flag-on first.
- **The picker sets the kind AND the id, and there is no longer a hand-edit path.** The plan asked
for "a `RawTree` picker for `ScopeId`", which would have left the kind dropdown beside it and kept
`RawDevice` + a TagId authorable. Selecting a tree node supplies both, which makes
`NodeAclScopeCrossCluster`, `NodeAclScopeIdUnexpected`, `NodeAclScopeIdMissing` and
`NodeAclScopeKindRetired` unauthorable by construction rather than merely discouraged.
- **It is a new component, not a third mode on `RawTree`.** `RawTree` already carries the `/raw`
editing tree and the equipment reference picker. A third mode would put the scope picker's bugs on
the device-authoring page, and with no bUnit in this repo the only thing that would catch that is
someone noticing. `AclScopeTree` renders a unified `AclScopeNode` instead, which is also what lets
ONE component serve both hierarchies — `RawTree` covers only the raw side.
- **Saving is blocked on an unresolved scope, and the list page marks the rot.** Not asked for; it is
what closes `ScopeId` dangling silently (the open item at the top of this doc) rather than moving
the discovery from deploy time to slightly-earlier deploy time. The check re-runs on submit rather
than trusting the verdict from when the form loaded, because a row loaded unchanged is exactly the
case where the operator never touches the picker.
Also fixed en route: the ACL editor had a `DataAnnotationsValidator` and **no `ValidationSummary`**,
so an invalid `NodeAclId` made the Create button a silent no-op — the [[project_adminui_no_bunit_live_verify]]
failure mode, present in this page since it was written.
8. **Rollout.** Docs, the ScadaBridge key hand-off, and flipping `Security:Acl:Enabled` on docker-dev
as the live gate.
@@ -350,6 +382,41 @@ Rig left as found: key, ACL row, the second device and its tag all deleted and r
returned to `Security:Acl:Enabled` unset, confirmed by the absence of the startup banner and an
anonymous read succeeding again.
## Stage 7 live gate — PASSED (docker-dev, 2026-08-01)
Driven through the AdminUI at `:9200` against cluster **SITE-A**. Both central containers were asserted
to be running the same freshly-built image *by container id*, not from one `docker compose build` — the
stale-central-2 trap from the stage-6 gate.
| # | Leg | Result |
|---|---|---|
| 1 | Picker lazily expands the real raw tree | ✅ cluster → `sa-modbus2` → `plc` → `SiteAOnly`, one level per expand |
| 2 | Each level offers its own scope kind | ✅ `CLUSTER` / `RAWDRIVER` / `RAWDEVICE` / `TAG` chips |
| 3 | Selecting a node sets kind **and** id | ✅ → `RAWDEVICE` + `SITEA-dev`, "✓ Device 'plc' in cluster 'SITE-A'" |
| 4 | Principal reaching nobody warns | ✅ `not-a-real-principal` → ⚠ "No active API key uses this principal" |
| 5 | …and the positive control | ✅ `anonymous` → ✓ "The reserved anonymous principal" |
| 6 | Grant saves and lists with its resolved target | ✅ |
| 7 | A rotted row is marked on the list | ✅ id broken by SQL → `UNRESOLVED` chip + reason |
| 8 | Saving a rotted row is **blocked** | ✅ "Scope: No device in this configuration has the id …" |
| 9 | Re-picking a valid scope unblocks it | ✅ `NAMESPACE raw` → saved |
| 10 | The authored grant **deploys** | ✅ `POST /api/deployments` → `Sealed` |
| 11 | The deploy gate rejects what the page rejects | ✅ same row broken by SQL → HTTP 422 `NodeAclScopeUnresolved` |
Legs 8 and 11 are the pair that matters: the page and the deploy gate reject the same row for the same
reason, which is the claim "`CheckScopeAsync` mirrors `DraftValidator`" reduces to. Leg 11 also
confirmed the *kind* is honoured on both sides — the row was re-pointed at `RawDriver`, and the gate's
message named `DriverInstance`.
Legs 5 and 9 are the positive controls. Without them the gate passes against a page that refuses
everything, which is the same failure shape stage 6's `resolvedScopes=N` leg exists to catch.
**Not covered:** a UNS-realm scope (SITE-A has no UNS areas, so the picker's UNS column correctly showed
"no UNS areas yet" and only the namespace row was selectable there). `UnsArea` / `UnsLine` / `Equipment`
selection is covered offline only.
Rig restored afterwards: the gate's ACL row deleted and a further deployment sealed, leaving SITE-A at
zero ACL rows as found.
## Known consequence to design around
**Driver-only nodes have no ConfigDb (Phase 4).** Keys and ACLs must therefore reach them inside the
+59 -13
View File
@@ -228,12 +228,13 @@ Two consequences worth knowing:
no roles. Admitting is the pre-#520 behaviour, and defaulting to it precisely when the server cannot
determine what anonymous may do stops being harmless once reads are gated.
⚠️ **Node ACLs are enforced only when `Security:Acl:Enabled` is set, and only for three operations.**
With the flag **off — the default** — a key's grants are collapsed into the two server-wide role gates
and **only `Cluster`-scoped grants count**, so a narrower grant confers nothing rather than silently
widening to server-wide authority. With it **on**, writes, alarm commands and HistoryRead are decided
by scoped `NodeAcl` grants; **reads, browses and subscriptions stay ungated in either mode** (stage 6).
See [Node ACL enforcement](#node-acl-enforcement-securityaclenabled) and
⚠️ **Node ACLs are enforced only when `Security:Acl:Enabled` is set.** With the flag **off — the
default** — a key's grants are collapsed into the two server-wide role gates and **only
`Cluster`-scoped grants count**, so a narrower grant confers nothing rather than silently widening to
server-wide authority, and read, browse, subscribe and HistoryRead are ungated for any admitted
session. With it **on**, **every** data-plane operation is decided by scoped `NodeAcl` grants — read,
browse, subscribe, write, HistoryRead and the alarm commands (stage 6). See
[Node ACL enforcement](#node-acl-enforcement-securityaclenabled) and
`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`.
### LDAP resilience — mostly retired with the data-plane authenticator (#520)
@@ -451,9 +452,10 @@ What is real about it:
necessary at stage 6's read/browse/subscribe seams, which are on the publish path — and it will want
a per-`(session, node)` decision cache, not this type.
`NodeAclScopeKind.FolderSegment` is the retired v2 kind with no v3 entity behind it. It is still offered
in the authoring dropdown (labelled retired), but it can never resolve, and both ends now say so
specifically rather than reporting it as a missing id: the deploy gate rejects it with
`NodeAclScopeKind.FolderSegment` is the retired v2 kind with no v3 entity behind it. The stage-7 picker
does not offer it at all, so it can only arrive on a legacy row or a hand-edited one; it can never
resolve, and every end now says so specifically rather than reporting it as a missing id: the AdminUI
refuses to save it, the deploy gate rejects it with
`NodeAclScopeKindRetired`, and the trie builder drops it with a `RetiredScopeKind` diagnostic (Gitea
#533 — it used to arrive as `UnresolvedScope`, which sent an operator holding a legacy row hunting for a
typo in an id that was never the problem). One inconsistency remains: `NodePermissions.AlarmRead` has no
@@ -462,13 +464,56 @@ typo in an id that was never the problem). One inconsistency remains: `NodePermi
**String identity in this subsystem** (Gitea #533): **identifiers** — `ScopeId`, `ClusterId`,
`Principal` — compare `OrdinalIgnoreCase`, because they are opaque handles whose spelling genuinely
varies by source (a `ClusterId` arrives lower-cased through `OTOPCUA_ROLES` and verbatim through
`Cluster:Roles`; a `ScopeId` is operator free-text until the stage-7 picker lands). **Path segments** —
`Cluster:Roles`; a `ScopeId` comes from the stage-7 picker but a legacy row's may be anything).
**Path segments** —
every level of a scope path, and therefore `PermissionTrieNode.Children` — compare `Ordinal`, because
they are browse names and `Speed` / `speed` are two different nodes that can coexist under one device.
The rule is stated in full on `AclScopeRef`. It matters most where the deploy gate and the node are fed
from *different* snapshots, which is exactly the split `AclScopePathResolver` exists to bridge: case
drift there would silently drop every grant on the affected scope.
### Authoring grants (#520 stage 7)
Grants are authored at **`/clusters/{clusterId}/acls`**. Two things about the surface are worth knowing
before relying on it, because both close a way a grant could previously be saved and deployed while
authorizing nobody.
**The scope kind and the scope target are chosen together, from a tree.** "Choose scope…" opens a
picker showing the cluster's **raw device tree** (lazily expanded, exactly the `/raw` hierarchy) beside
its **UNS tree**, plus a whole-namespace row on each side. Selecting a node sets `ScopeKind` *and*
`ScopeId` in one action. Until this landed the two were an unrelated dropdown and a free-text box, so
`RawDevice` + a TagId was a well-formed row: it saved, it deployed, and it resolved to nothing.
Consequences of the trees being rooted at the grant's own cluster and of a node supplying its own kind:
- A **cross-cluster** scope is structurally unreachable — the picker's counterpart to the deploy gate's
`NodeAclScopeCrossCluster` rule, rather than a second copy of it.
- A **Cluster** row deliberately produces **no** `ScopeId`. The deploy gate rejects a Cluster grant that
carries one (`NodeAclScopeIdUnexpected`), so a picker that helpfully filled in the cluster id would
author rows that cannot be deployed.
- **UNS tags are not offered.** A UNS signal is a `UnsTagReference` to a raw tag, and a `Tag` grant
already covers the raw node *and* every UNS projection of it — so a UNS tag row would produce a grant
that reads narrower than the one it creates. Pick the tag in the raw tree.
- `FolderSegment` is not offered at all (see above).
**Rows are resolved against the live configuration, at authoring time.** `IAclScopeService.CheckScopeAsync`
mirrors `DraftValidator.ValidateNodeAclScopes` rule for rule, so the page rejects what the deployment
would reject:
- The editor **refuses to save** a grant whose scope does not resolve — re-checked on submit, not
trusted from when the form loaded, because an existing row's entity may have been deleted since and
the operator may never touch the picker.
- The **list page** resolves every row and marks a broken one `unresolved` with the reason, so a grant
that has rotted since it was authored is visible without waiting for a deploy to fail.
- The **principal** field suggests every known principal (API keys, existing grants, `anonymous`) and
reports how many *live* keys currently authenticate as the one typed. Reaching nobody is a **warning,
not a block** — authoring a grant before minting its key is legitimate; a typo is not, and the two are
indistinguishable to the server.
The checks are in `AclScopeService` rather than in the Razor pages because this repo has no bUnit:
logic only a page can reach is untestable, and "the grant authorizes nobody" is not a failure mode to
leave unpinned.
### Hierarchy (design)
ACLs are evaluated against the node's scope path. `NodeScope` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs`) carries a `Kind` that selects between two hierarchy shapes:
@@ -567,9 +612,10 @@ to those role strings via `GroupToRole`, e.g.:
⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and
`AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and
`WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group
to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (reads are ungated for
everyone regardless). Earlier revisions of this document listed all five as "code-true"; that was
wrong for three of them.
to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (with the flag off,
reads are ungated for everyone regardless; with it on, these role strings are not consulted at all —
the scoped ACL decision replaces them). Earlier revisions of this document listed all five as
"code-true"; that was wrong for three of them.
If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return
`BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both
@@ -0,0 +1,111 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
/// <summary>
/// One row in the ACL scope picker (#520 stage 7) — a place a <c>NodeAcl</c> grant can be scoped at,
/// in either the raw device tree or the UNS tree.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a third tree view-model rather than reusing <see cref="RawNode"/>.</b> The picker's job is
/// to produce a <see cref="NodeAclScopeKind"/> and a <c>ScopeId</c> <i>together</i>, from both
/// hierarchies, in one list. <see cref="RawNode"/> covers only the raw side and <see cref="UnsNode"/>
/// only the UNS side, and neither carries a scope kind — so a shared shape is what lets one renderer
/// and one selection model serve both. The raw side keeps its lazy loading by carrying its source
/// node in <see cref="RawSource"/>.
/// </para>
/// <para>
/// A node with a null <see cref="ScopeKind"/> is a label only (the enterprise/cluster heading rows,
/// and UNS tag rows — see <see cref="IAclScopeService"/> for why those are deliberately not
/// selectable).
/// </para>
/// </remarks>
public sealed class AclScopeNode
{
/// <summary>Gets the stable per-node key, unique within the picker tree.</summary>
public required string Key { get; init; }
/// <summary>Gets the label shown in the tree.</summary>
public required string DisplayName { get; init; }
/// <summary>Gets the leading glyph, matching the icon vocabulary of the /raw and /uns trees.</summary>
public required string Icon { get; init; }
/// <summary>
/// Gets the scope kind this row grants at, or <see langword="null"/> when the row is a label that
/// cannot be selected.
/// </summary>
public NodeAclScopeKind? ScopeKind { get; init; }
/// <summary>
/// Gets the <c>NodeAcl.ScopeId</c> this row produces. Null for a <see cref="NodeAclScopeKind.Cluster"/>
/// row, which covers its whole cluster and carries no id, and for a label row.
/// </summary>
public string? ScopeId { get; init; }
/// <summary>Gets the ancestry label shown once selected, e.g. <c>Plant / Modbus / dev1</c>.</summary>
public required string PathLabel { get; init; }
/// <summary>Gets a value indicating whether this node's children load on first expand.</summary>
public bool HasLazyChildren { get; init; }
/// <summary>Gets the eagerly-materialised children (the whole UNS side; the raw side's roots only).</summary>
public List<AclScopeNode> Children { get; } = new();
/// <summary>
/// Gets the raw-tree node this row projects, when there is one. The picker hands it back to
/// <see cref="IAclScopeService.LoadChildrenAsync"/> to expand the next level; null on the UNS side,
/// which is loaded whole.
/// </summary>
public RawNode? RawSource { get; init; }
// --- Transient UI state (never persisted) ---
/// <summary>Gets or sets a value indicating whether the node is expanded.</summary>
public bool Expanded { get; set; }
/// <summary>Gets or sets a value indicating whether the lazy children have been loaded.</summary>
public bool Loaded { get; set; }
/// <summary>Gets or sets a value indicating whether a lazy load is in flight.</summary>
public bool Loading { get; set; }
/// <summary>Gets or sets the last load error, if any.</summary>
public string? Error { get; set; }
/// <summary>Gets a value indicating whether this row can be chosen as a grant scope.</summary>
public bool Selectable => ScopeKind is not null;
}
/// <summary>The two scope trees for one cluster, as the picker shows them.</summary>
/// <param name="Raw">The raw device tree, rooted at the cluster; empty when the cluster is unknown.</param>
/// <param name="Uns">The UNS tree (area → line → equipment) for the same cluster.</param>
public readonly record struct AclScopeRoots(
IReadOnlyList<AclScopeNode> Raw,
IReadOnlyList<AclScopeNode> Uns);
/// <summary>
/// The outcome of resolving one authored <c>(ScopeKind, ScopeId)</c> pair against the current
/// configuration — the authoring-time mirror of the deploy gate's <c>NodeAclScope*</c> rules.
/// </summary>
/// <param name="Ok">Whether the scope names something the grant can actually be evaluated against.</param>
/// <param name="Message">
/// What it resolves to when <paramref name="Ok"/>, or what is wrong and how to fix it when not.
/// </param>
public readonly record struct AclScopeCheck(bool Ok, string Message);
/// <summary>How many API keys a principal name currently reaches.</summary>
/// <param name="IsAnonymous">Whether this is the reserved <c>anonymous</c> principal, which has no key.</param>
/// <param name="EnabledKeys">Enabled, unexpired keys authenticating as this principal.</param>
/// <param name="DisabledKeys">Keys naming this principal that are disabled or already expired.</param>
public readonly record struct PrincipalUsage(bool IsAnonymous, int EnabledKeys, int DisabledKeys)
{
/// <summary>
/// Gets a value indicating whether anything can currently present this principal. False means the
/// grant is authored against nobody — which is legitimate while a key is still to be minted, and a
/// typo otherwise, so it is surfaced rather than blocked.
/// </summary>
public bool ReachesSomething => IsAnonymous || EnabledKeys > 0;
}
@@ -0,0 +1,363 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
/// <summary>EF-backed <see cref="IAclScopeService"/>.</summary>
/// <remarks>
/// The two trees are projected from the services that already own them —
/// <see cref="IRawTreeService"/> for the raw side (keeping its lazy expansion) and
/// <see cref="IUnsTreeService"/> for the UNS side (which loads whole) — so the picker cannot show a
/// hierarchy that disagrees with <c>/raw</c> or <c>/uns</c>. Only <see cref="CheckScopeAsync"/> and the
/// principal lookups go to the database directly.
/// </remarks>
public sealed class AclScopeService : IAclScopeService
{
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly IRawTreeService _raw;
private readonly IUnsTreeService _uns;
private readonly TimeProvider _time;
/// <summary>Initializes a new instance of the <see cref="AclScopeService"/> class.</summary>
/// <param name="dbFactory">The config DB context factory.</param>
/// <param name="raw">The raw project-tree service.</param>
/// <param name="uns">The UNS tree service.</param>
/// <param name="time">Clock used to decide whether a key has already expired.</param>
public AclScopeService(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IRawTreeService raw,
IUnsTreeService uns,
TimeProvider? time = null)
{
_dbFactory = dbFactory;
_raw = raw;
_uns = uns;
_time = time ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<AclScopeRoots> LoadRootsAsync(string clusterId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
// Both services root their trees at Enterprise → Cluster, but searching the roots as well as their
// children keeps this working if a cluster ever surfaces at the top level.
var rawRoots = await _raw.LoadRootsAsync(ct).ConfigureAwait(false);
var rawCluster = rawRoots
.Concat(rawRoots.SelectMany(r => r.Children))
.FirstOrDefault(n => n.Kind == RawNodeKind.Cluster
&& string.Equals(n.EntityId, clusterId, StringComparison.Ordinal));
var unsRoots = await _uns.LoadStructureAsync(ct).ConfigureAwait(false);
var unsCluster = unsRoots
.Concat(unsRoots.SelectMany(r => r.Children))
.FirstOrDefault(n => n.Kind == UnsNodeKind.Cluster
&& string.Equals(n.EntityId, clusterId, StringComparison.Ordinal));
var raw = rawCluster is null
? Array.Empty<AclScopeNode>()
: new[] { ProjectRaw(rawCluster, parentPath: null) };
var uns = unsCluster is null
? Array.Empty<AclScopeNode>()
: ProjectUnsChildren(unsCluster, parentPath: null);
return new AclScopeRoots(raw, uns);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AclScopeNode>> LoadChildrenAsync(
AclScopeNode parent, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(parent);
if (parent.RawSource is null) return Array.Empty<AclScopeNode>();
var children = await _raw.LoadChildrenAsync(parent.RawSource, ct).ConfigureAwait(false);
return children.Select(c => ProjectRaw(c, parent.PathLabel)).ToList();
}
/// <inheritdoc />
public async Task<AclScopeCheck> CheckScopeAsync(
string clusterId, NodeAclScopeKind kind, string? scopeId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
// Mirrors DraftValidator.ValidateNodeAclScopes, in its order, so the page and the deploy gate
// cannot disagree about which rows are authorable. The messages are shorter because the operator
// is looking at the field; the rules are the same.
if (!Enum.IsDefined(kind))
return new AclScopeCheck(false,
$"Scope kind ordinal {(int)kind} is not a defined kind — re-pick the scope.");
if (kind == NodeAclScopeKind.Cluster)
return string.IsNullOrWhiteSpace(scopeId)
? new AclScopeCheck(true, $"The whole of cluster '{clusterId}', in both namespaces.")
: new AclScopeCheck(false,
"A Cluster grant covers the whole cluster and ignores the scope target — clear the " +
"target, or pick the level the id names. Leaving both would grant more than intended.");
if (kind == NodeAclScopeKind.FolderSegment)
return new AclScopeCheck(false,
"FolderSegment is a retired v2 kind with no v3 entity behind it; it can never resolve, " +
"whatever its target. Re-scope to RawFolder.");
if (string.IsNullOrWhiteSpace(scopeId))
return new AclScopeCheck(false,
$"{kind} needs a scope target. Only a Cluster grant may omit one; without it the grant " +
"resolves to no node and is dropped when the configuration is built.");
var id = scopeId.Trim();
if (kind == NodeAclScopeKind.Namespace)
{
var realm = NodeAclRealms.TryCanonicalize(id);
return realm is null
? new AclScopeCheck(false,
$"'{id}' names no OPC UA namespace. Use '{NodeAclRealms.Raw}' or '{NodeAclRealms.Uns}'.")
: new AclScopeCheck(true,
realm == NodeAclRealms.Raw
? $"Every raw node in cluster '{clusterId}'."
: $"Every UNS node in cluster '{clusterId}'.");
}
await using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
// Each arm names the entity type the kind's ScopeId is drawn from, and walks to the cluster that
// owns it. Switch arms evaluate lazily, so only the matched kind's query runs. No AsNoTracking:
// every arm projects into Found, and EF tracks nothing it does not materialise as an entity.
var (entity, owner, name) = kind switch
{
NodeAclScopeKind.RawFolder => await ResolveAsync(
"raw folder",
db.RawFolders.Where(f => f.RawFolderId == id).Select(f => new Found(f.ClusterId, f.Name)),
ct).ConfigureAwait(false),
NodeAclScopeKind.RawDriver => await ResolveAsync(
"driver",
db.DriverInstances.Where(d => d.DriverInstanceId == id).Select(d => new Found(d.ClusterId, d.Name)),
ct).ConfigureAwait(false),
NodeAclScopeKind.RawDevice => await ResolveAsync(
"device",
from d in db.Devices
where d.DeviceId == id
join dr in db.DriverInstances on d.DriverInstanceId equals dr.DriverInstanceId
select new Found(dr.ClusterId, d.Name),
ct).ConfigureAwait(false),
NodeAclScopeKind.RawTagGroup => await ResolveAsync(
"tag group",
from g in db.TagGroups
where g.TagGroupId == id
join dev in db.Devices on g.DeviceId equals dev.DeviceId
join dr in db.DriverInstances on dev.DriverInstanceId equals dr.DriverInstanceId
select new Found(dr.ClusterId, g.Name),
ct).ConfigureAwait(false),
NodeAclScopeKind.Tag => await ResolveAsync(
"raw tag",
from t in db.Tags
where t.TagId == id
join dev in db.Devices on t.DeviceId equals dev.DeviceId
join dr in db.DriverInstances on dev.DriverInstanceId equals dr.DriverInstanceId
select new Found(dr.ClusterId, t.Name),
ct).ConfigureAwait(false),
NodeAclScopeKind.UnsArea => await ResolveAsync(
"UNS area",
db.UnsAreas.Where(a => a.UnsAreaId == id).Select(a => new Found(a.ClusterId, a.Name)),
ct).ConfigureAwait(false),
NodeAclScopeKind.UnsLine => await ResolveAsync(
"UNS line",
from l in db.UnsLines
where l.UnsLineId == id
join a in db.UnsAreas on l.UnsAreaId equals a.UnsAreaId
select new Found(a.ClusterId, l.Name),
ct).ConfigureAwait(false),
NodeAclScopeKind.Equipment => await ResolveAsync(
"equipment",
from e in db.Equipment
where e.EquipmentId == id
join l in db.UnsLines on e.UnsLineId equals l.UnsLineId
join a in db.UnsAreas on l.UnsAreaId equals a.UnsAreaId
select new Found(a.ClusterId, e.Name),
ct).ConfigureAwait(false),
// Cluster / Namespace / FolderSegment all returned above, and an undefined ordinal did too.
_ => ("scope", null, null),
};
if (owner is null)
return new AclScopeCheck(false,
$"No {entity} in this configuration has the id '{id}' — or its parent chain is broken, so " +
"no cluster owns it. The grant would be dropped when the configuration is built and would " +
"authorize nothing.");
if (!string.Equals(owner, clusterId, StringComparison.OrdinalIgnoreCase))
return new AclScopeCheck(false,
$"That {entity} belongs to cluster '{owner}', but this grant is in cluster '{clusterId}'. " +
"The id is real, so the grant would confer authority across a cluster boundary. Author it " +
$"under '{owner}' instead.");
return new AclScopeCheck(true,
$"{char.ToUpperInvariant(entity[0])}{entity[1..]} '{name}' in cluster '{clusterId}'.");
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> LoadKnownPrincipalsAsync(CancellationToken ct = default)
{
await using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var fromKeys = await db.ApiKeys.AsNoTracking().Select(k => k.Principal).Distinct()
.ToListAsync(ct).ConfigureAwait(false);
var fromAcls = await db.NodeAcls.AsNoTracking().Select(a => a.Principal).Distinct()
.ToListAsync(ct).ConfigureAwait(false);
return fromKeys
.Concat(fromAcls)
.Append(OpcUaPrincipals.Anonymous)
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <inheritdoc />
public async Task<PrincipalUsage> CheckPrincipalAsync(string principal, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(principal)) return new PrincipalUsage(false, 0, 0);
var name = principal.Trim();
if (string.Equals(name, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase))
return new PrincipalUsage(true, 0, 0);
await using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var keys = await db.ApiKeys.AsNoTracking()
.Where(k => k.Principal == name)
.Select(k => new { k.Enabled, k.ExpiresUtc })
.ToListAsync(ct).ConfigureAwait(false);
var now = _time.GetUtcNow().UtcDateTime;
var live = keys.Count(k => k.Enabled && (k.ExpiresUtc is null || k.ExpiresUtc > now));
return new PrincipalUsage(false, live, keys.Count - live);
}
// --- Projection --------------------------------------------------------------------------------
private static AclScopeNode ProjectRaw(RawNode node, string? parentPath)
{
var kind = RawScopeKind(node.Kind);
var path = Join(parentPath, node.DisplayName);
var projected = new AclScopeNode
{
Key = node.Key,
DisplayName = node.DisplayName,
Icon = RawIcon(node.Kind),
ScopeKind = kind,
// A Cluster grant covers its whole cluster and carries no id — the deploy gate rejects one
// that does, so the picker must not produce one.
ScopeId = kind == NodeAclScopeKind.Cluster ? null : node.EntityId,
PathLabel = path,
HasLazyChildren = node.HasLazyChildren,
RawSource = node,
};
foreach (var child in node.Children) projected.Children.Add(ProjectRaw(child, path));
return projected;
}
private static IReadOnlyList<AclScopeNode> ProjectUnsChildren(UnsNode cluster, string? parentPath) =>
cluster.Children.Select(c => ProjectUns(c, parentPath)).ToList();
private static AclScopeNode ProjectUns(UnsNode node, string? parentPath)
{
var path = Join(parentPath, node.DisplayName);
var projected = new AclScopeNode
{
Key = node.Key,
DisplayName = node.DisplayName,
Icon = UnsIcon(node.Kind),
ScopeKind = UnsScopeKind(node.Kind),
ScopeId = node.EntityId,
PathLabel = path,
HasLazyChildren = false,
// Everything below the cluster is already materialised — nothing to expand lazily, and no raw
// node to expand it from.
RawSource = null,
Expanded = true,
};
foreach (var child in node.Children) projected.Children.Add(ProjectUns(child, path));
return projected;
}
private static string Join(string? parent, string name) =>
string.IsNullOrEmpty(parent) ? name : $"{parent} / {name}";
private static NodeAclScopeKind? RawScopeKind(RawNodeKind kind) => kind switch
{
RawNodeKind.Cluster => NodeAclScopeKind.Cluster,
RawNodeKind.Folder => NodeAclScopeKind.RawFolder,
RawNodeKind.Driver => NodeAclScopeKind.RawDriver,
RawNodeKind.Device => NodeAclScopeKind.RawDevice,
RawNodeKind.TagGroup => NodeAclScopeKind.RawTagGroup,
RawNodeKind.Tag => NodeAclScopeKind.Tag,
_ => null, // Enterprise: a label, not a scope.
};
// Area/Line/Equipment only. A UNS tag row is a UnsTagReference, not a Tag — its scope is the RAW tag it
// points at, and a Tag grant already covers the raw node AND every UNS projection of it (see
// AclScopePathResolver). Offering a UNS tag here would produce a grant that reads as narrower than the
// one it actually creates.
private static NodeAclScopeKind? UnsScopeKind(UnsNodeKind kind) => kind switch
{
UnsNodeKind.Area => NodeAclScopeKind.UnsArea,
UnsNodeKind.Line => NodeAclScopeKind.UnsLine,
UnsNodeKind.Equipment => NodeAclScopeKind.Equipment,
_ => null,
};
private static string RawIcon(RawNodeKind kind) => kind switch
{
RawNodeKind.Enterprise => "🏢",
RawNodeKind.Cluster => "🖥️",
RawNodeKind.Folder => "📁",
RawNodeKind.Driver => "🔌",
RawNodeKind.Device => "📟",
RawNodeKind.TagGroup => "🗂️",
RawNodeKind.Tag => "🏷️",
_ => "•",
};
private static string UnsIcon(UnsNodeKind kind) => kind switch
{
UnsNodeKind.Area => "🗺️",
UnsNodeKind.Line => "🏭",
UnsNodeKind.Equipment => "⚙️",
_ => "•",
};
// --- Resolution helpers ------------------------------------------------------------------------
// A class, not a struct: FirstOrDefaultAsync over a struct yields default(T), which is indistinguishable
// from a real row whose fields happen to be empty — a null reference cannot be misread that way.
private sealed record Found(string ClusterId, string Name);
private static async Task<(string Entity, string? Owner, string? Name)> ResolveAsync(
string entity, IQueryable<Found> query, CancellationToken ct)
{
// FirstOrDefault, not Single: a duplicated id is a different rule's business, and throwing here
// would replace a precise message with an exception page.
var hit = await query.FirstOrDefaultAsync(ct).ConfigureAwait(false);
return hit is null ? (entity, null, null) : (entity, hit.ClusterId, hit.Name);
}
}
@@ -0,0 +1,72 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
/// <summary>
/// Backs the ACL authoring surface (#520 stage 7): the scope picker's two trees, and the
/// authoring-time checks that stop a grant being saved against a principal or a scope that names
/// nothing.
/// </summary>
/// <remarks>
/// <para>
/// <b>A service rather than logic in the Razor pages, because this repo has no bUnit</b> — anything
/// only a page can reach is untestable, and the whole point of the stage is that authoring stops
/// producing grants that silently authorize nobody. The same rationale
/// <c>IApiKeyAdminService</c> carries.
/// </para>
/// <para>
/// <b>The picker chooses the kind and the id together.</b> Before this, <c>ScopeKind</c> was a
/// dropdown and <c>ScopeId</c> a free-text box, so the two could disagree — a
/// <see cref="NodeAclScopeKind.RawDevice"/> row pointing at a TagId is well-formed, saves, deploys,
/// and resolves to nothing. Selecting a tree node produces both, so that pairing cannot be made by
/// hand. <see cref="CheckScopeAsync"/> is what catches the rows that already exist, or that were
/// typed in before the picker was reached.
/// </para>
/// </remarks>
public interface IAclScopeService
{
/// <summary>
/// Loads the raw and UNS scope trees for one cluster. Both are rooted at that cluster, so a scope in
/// another cluster is unreachable through the picker rather than merely discouraged — the structural
/// equivalent of the deploy gate's <c>NodeAclScopeCrossCluster</c> rule.
/// </summary>
/// <param name="clusterId">The cluster the grant belongs to.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The two trees; either may be empty when the cluster has nothing of that kind yet.</returns>
Task<AclScopeRoots> LoadRootsAsync(string clusterId, CancellationToken ct = default);
/// <summary>
/// Loads one lazy level beneath a raw-tree node. UNS nodes are loaded whole by
/// <see cref="LoadRootsAsync"/> and never reach here.
/// </summary>
/// <param name="parent">The node being expanded.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The child rows, or empty for a leaf.</returns>
Task<IReadOnlyList<AclScopeNode>> LoadChildrenAsync(AclScopeNode parent, CancellationToken ct = default);
/// <summary>
/// Resolves an authored scope against the current configuration, mirroring the deploy gate's
/// <c>NodeAclScope*</c> rules so the page rejects what the deployment would reject.
/// </summary>
/// <param name="clusterId">The cluster the grant belongs to.</param>
/// <param name="kind">The authored scope kind.</param>
/// <param name="scopeId">The authored scope id; null or blank for a cluster-wide grant.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>What the scope resolves to, or why it does not.</returns>
Task<AclScopeCheck> CheckScopeAsync(
string clusterId, NodeAclScopeKind kind, string? scopeId, CancellationToken ct = default);
/// <summary>
/// Lists the principal names worth suggesting: every principal an API key authenticates as, every
/// principal an existing grant already names, and the reserved <c>anonymous</c> principal.
/// </summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>Distinct principal names, ordered.</returns>
Task<IReadOnlyList<string>> LoadKnownPrincipalsAsync(CancellationToken ct = default);
/// <summary>Reports how many API keys currently authenticate as a principal.</summary>
/// <param name="principal">The principal name as authored.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The usage counts.</returns>
Task<PrincipalUsage> CheckPrincipalAsync(string principal, CancellationToken ct = default);
}
@@ -5,10 +5,13 @@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
@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
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject IAclScopeService Scopes
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -28,47 +31,35 @@ else if (!IsNew && _existing is null)
}
else
{
<div class="alert alert-warning" role="alert">
<div class="alert alert-info" role="alert">
<strong>What this grant does depends on <span class="mono">Security:Acl:Enabled</span> on the
node serving the data.</strong> That is per-node configuration, so this page cannot tell you
which mode a given node is in — check the node's own settings.
<br />
<strong>With it off (the default):</strong> saved with scope kind
<span class="mono">Cluster</span> this grant confers a <em>server-wide</em> role on its
principal — <span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
(acknowledge / confirm / shelve). Saved with any narrower scope
(<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
<span class="mono">Tag</span>, <span class="mono">Raw*</span>) it is stored and shipped in every
deployment artifact but <strong>confers nothing</strong>. That under-granting is deliberate:
honouring its flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on one
equipment" into server-wide write authority.
<br />
<strong>With it on:</strong> the scope is honoured, and the scoped decision <em>replaces</em> the
server-wide role rather than adding to it. <strong>Every data-plane operation is gated</strong> —
<span class="mono">Read</span>, <span class="mono">Browse</span>,
<span class="mono">Subscribe</span>, <span class="mono">WriteOperate</span>,
<span class="mono">HistoryRead</span> and the alarm methods, which separate into
<span class="mono">AlarmAcknowledge</span> / <span class="mono">AlarmConfirm</span> /
<span class="mono">AlarmShelve</span>. So a grant that omits
<span class="mono">Read</span> denies reads, and one that omits
<span class="mono">Browse</span> hides the node from navigation.
<br />
<span class="mono">AlarmShelve</span>. So a grant that omits <span class="mono">Read</span>
denies reads, and one that omits <span class="mono">Browse</span> hides the node from navigation.
Granting <span class="mono">Browse</span> on a node also makes the folders <em>above</em> it
browsable — otherwise the grant could never be navigated to — so it reveals their names.
Revoking this grant stops any live subscription it was authorizing, on the next deployment.
<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_&lt;keyId&gt;_&lt;secret&gt;</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>.
<strong>With it off (the default):</strong> only a <span class="mono">Cluster</span>-scoped grant
does anything, and it confers a <em>server-wide</em> role on its principal —
<span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods. Any narrower scope is
stored and shipped but <strong>confers nothing</strong>. That under-granting is deliberate:
honouring a grant's flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on
one equipment" into server-wide write authority.
</div>
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
<DataAnnotationsValidator />
<ValidationSummary class="alert alert-danger" />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Grant</div>
<div style="padding:1rem">
@@ -79,49 +70,71 @@ else
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="grp">Principal</label>
<InputText id="grp" @bind-Value="_form.Principal" class="form-control form-control-sm mono"
placeholder="scadabridge-dcl" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="scope">Scope kind</label>
<InputSelect id="scope" @bind-Value="_form.ScopeKind" class="form-select form-select-sm">
<option value="@NodeAclScopeKind.Cluster">Cluster</option>
<option value="@NodeAclScopeKind.Namespace">Namespace</option>
<option value="@NodeAclScopeKind.UnsArea">UnsArea</option>
<option value="@NodeAclScopeKind.UnsLine">UnsLine</option>
<option value="@NodeAclScopeKind.Equipment">Equipment</option>
<option value="@NodeAclScopeKind.FolderSegment">FolderSegment (retired — do not author)</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>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="target">Scope target ID</label>
<InputText id="target" @bind-Value="_form.ScopeId" class="form-control form-control-sm mono"
placeholder="Leave blank for cluster-wide" />
<input id="grp" class="form-control form-control-sm mono" list="acl-principals"
placeholder="scadabridge-dcl"
@bind="_form.Principal" @bind:after="RefreshPrincipalAsync" />
<datalist id="acl-principals">
@foreach (var p in _knownPrincipals)
{
<option value="@p"></option>
}
</datalist>
<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.
@if (string.IsNullOrWhiteSpace(_form.Principal))
{
<span>
An API key's principal, or the reserved
<span class="mono">@OpcUaPrincipals.Anonymous</span> principal that an
anonymous session authenticates as. Mint keys on
<a href="/apikeys">/apikeys</a>.
</span>
}
else if (_principal.IsAnonymous)
{
<span class="text-success">
✓ The reserved anonymous principal — every session admitted without a
key authenticates as this.
</span>
}
else if (_principal.EnabledKeys > 0)
{
<span class="text-success">
✓ @_principal.EnabledKeys active API key@(_principal.EnabledKeys == 1 ? "" : "s")
@(_principal.DisabledKeys > 0 ? $" (+{_principal.DisabledKeys} revoked or expired)" : "")
authenticate@(_principal.EnabledKeys == 1 ? "s" : "") as this principal.
</span>
}
else
{
<span class="text-warning">
⚠ No active API key uses this principal@(_principal.DisabledKeys > 0 ? " (only revoked or expired ones)" : "") —
the grant is valid but currently authorizes nobody. That is fine if the
key is still to be minted; otherwise check the spelling.
</span>
}
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Scope</label>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="chip chip-idle">@_form.ScopeKind</span>
<span class="mono small">@(string.IsNullOrEmpty(_form.ScopeId) ? "(whole cluster)" : _form.ScopeId)</span>
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="OpenPicker">
Choose scope…
</button>
</div>
<div class="form-text @(_scope.Ok ? "text-success" : "text-danger")">
@(_scope.Ok ? "✓ " : "✗ ")@_scope.Message
</div>
<div class="form-text">
Permissions inherit downward: a grant on a device covers every tag beneath it.
Picking the scope from the tree is what keeps the kind and the target in step —
a kind that disagrees with its target resolves to nothing and authorizes nobody.
</div>
</div>
<div class="mb-3">
<label class="form-label">Permissions</label>
<div>
@@ -162,6 +175,9 @@ else
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
</div>
</EditForm>
<AclScopePickerModal Visible="_pickerVisible" ClusterId="@ClusterId"
OnPicked="ScopePicked" OnCancel="() => _pickerVisible = false" />
}
@code {
@@ -183,6 +199,11 @@ else
private bool _loaded;
private bool _busy;
private string? _error;
private bool _pickerVisible;
private IReadOnlyList<string> _knownPrincipals = Array.Empty<string>();
private PrincipalUsage _principal;
private AclScopeCheck _scope;
protected override async Task OnInitializedAsync()
{
@@ -205,14 +226,46 @@ else
};
}
}
_knownPrincipals = await Scopes.LoadKnownPrincipalsAsync();
await RefreshPrincipalAsync();
await RefreshScopeAsync();
_loaded = true;
}
private void OpenPicker()
{
_pickerVisible = true;
StateHasChanged();
}
private async Task ScopePicked(AclScopeNode node)
{
// The picker supplies both halves at once — the pairing is what stops a kind naming one entity
// type while its id names another.
_form.ScopeKind = node.ScopeKind!.Value;
_form.ScopeId = node.ScopeId;
_pickerVisible = false;
await RefreshScopeAsync();
}
private async Task RefreshPrincipalAsync() =>
_principal = await Scopes.CheckPrincipalAsync(_form.Principal ?? "");
private async Task RefreshScopeAsync() =>
_scope = await Scopes.CheckScopeAsync(ClusterId, _form.ScopeKind, _form.ScopeId);
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
// Re-check rather than trust the cached verdict: an existing row may have been loaded with a
// scope whose entity has since been deleted, and the operator may never have touched the
// picker. Blocking here is what stops the page saving a grant the deployment would reject.
await RefreshScopeAsync();
if (!_scope.Ok) { _error = $"Scope: {_scope.Message}"; return; }
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -2,9 +2,11 @@
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject IAclScopeService Scopes
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">ACLs &middot; <span class="mono">@ClusterId</span></h4>
@@ -19,24 +21,12 @@
}
else
{
<div class="alert alert-warning" role="alert">
<div class="alert alert-info" role="alert">
<strong>What these grants do depends on
<span class="mono">Security:Acl:Enabled</span> on the node serving the data.</strong>
That is per-node configuration, so this page cannot tell you which mode a given node is in —
check the node's own settings.
<br />
<strong>With it off (the default):</strong> only <span class="mono">Cluster</span>-scoped grants
do anything, and they confer a <em>server-wide</em> role on their principal —
<span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
(acknowledge / confirm / shelve). Narrower grants
(<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
<span class="mono">Tag</span>, <span class="mono">Raw*</span>) are saved and shipped in every
deployment artifact but confer <strong>nothing</strong>. That under-granting is deliberate:
honouring such a grant's flags while its <em>scope</em> cannot be honoured would turn
"WriteOperate on one equipment" into server-wide write authority.
<br />
<strong>With it on:</strong> every scope kind is honoured, and the scoped decision
<em>replaces</em> the server-wide role rather than adding to it — so a narrow grant works, and a
Cluster-scoped one no longer confers authority beyond that cluster.
@@ -47,6 +37,15 @@ else
<span class="mono">AlarmConfirm</span> / <span class="mono">AlarmShelve</span>. A principal with
no grant at all can connect and see nothing.
<br />
<strong>With it off (the default):</strong> only <span class="mono">Cluster</span>-scoped grants
do anything, and they confer a <em>server-wide</em> role on their principal —
<span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
(acknowledge / confirm / shelve). Narrower grants are saved and shipped in every deployment
artifact but confer <strong>nothing</strong>. That under-granting is deliberate: honouring such
a grant's flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on one
equipment" into server-wide write authority.
<br />
A node that merely <em>leads to</em> something you may browse stays browsable, or a narrow grant
could never be navigated to — so granting <span class="mono">Browse</span> deep in the tree also
reveals the names of the folders above it. Revoking a grant stops live subscriptions on the next
@@ -64,13 +63,23 @@ else
(the cluster, a namespace, an area, an equipment, a raw device, a tag). A principal is an API
key's principal, or the reserved <span class="mono">anonymous</span> principal that an
anonymous session authenticates as.
<br />
The <strong>Scope target</strong> column resolves each row against the current configuration. A
row marked <span class="chip chip-alert">unresolved</span> points at nothing — it is shipped in
every deployment artifact but authorizes nobody, and the deploy gate rejects it. Open it and
re-pick the scope.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count ACL row@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<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>
<div style="padding:1rem" class="text-muted">
No ACL rows for this cluster. With <span class="mono">Security:Acl:Enabled</span> off, that
means writes and alarm acknowledges are refused while reads, browses, subscriptions and
history reads stay open to any admitted session. With it <strong>on</strong>, it means
every data-plane operation is refused here — a client can connect and see nothing.
</div>
}
else
{
@@ -92,9 +101,29 @@ else
{
<tr>
<td><span class="mono small">@a.NodeAclId</span></td>
<td><span class="mono">@a.Principal</span></td>
<td>
<span class="mono">@a.Principal</span>
@if (_principals.TryGetValue(a.Principal, out var use) && !use.ReachesSomething)
{
<span class="chip chip-warn ms-1" title="No active API key authenticates as this principal, so the grant currently authorizes nobody.">no key</span>
}
</td>
<td>@a.ScopeKind</td>
<td><span class="mono small">@(a.ScopeId ?? "—")</span></td>
<td>
<span class="mono small">@(a.ScopeId ?? "—")</span>
@if (_scopes.TryGetValue(a.NodeAclId, out var check))
{
@if (check.Ok)
{
<div class="text-muted small">@check.Message</div>
}
else
{
<div><span class="chip chip-alert" title="@check.Message">unresolved</span></div>
<div class="text-danger small">@check.Message</div>
}
}
</td>
<td>
@foreach (var perm in PermissionChips(a.PermissionFlags))
{
@@ -115,6 +144,8 @@ else
@code {
[Parameter] public string ClusterId { get; set; } = "";
private List<NodeAcl>? _rows;
private Dictionary<string, AclScopeCheck> _scopes = new(StringComparer.Ordinal);
private Dictionary<string, PrincipalUsage> _principals = new(StringComparer.OrdinalIgnoreCase);
protected override async Task OnInitializedAsync()
{
@@ -123,6 +154,19 @@ else
.Where(a => a.ClusterId == ClusterId)
.OrderBy(a => a.NodeAclId)
.ToListAsync();
// Resolve every row's scope and principal so an unresolved grant is visible from the list rather
// than only at deploy time. One indexed lookup each; the distinct-principal grouping keeps the
// key lookups down to the number of distinct principals, not the number of rows.
foreach (var row in _rows)
{
_scopes[row.NodeAclId] = await Scopes.CheckScopeAsync(ClusterId, row.ScopeKind, row.ScopeId);
}
foreach (var principal in _rows.Select(r => r.Principal).Distinct(StringComparer.OrdinalIgnoreCase))
{
_principals[principal] = await Scopes.CheckPrincipalAsync(principal);
}
}
private static IEnumerable<string> PermissionChips(ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodePermissions flags)
@@ -0,0 +1,190 @@
@* #520 stage 7 — pick a NodeAcl grant's scope from the real configuration.
The point of this modal is that it produces the ScopeKind and the ScopeId TOGETHER, from a node that
exists. Before it, the two were an unrelated dropdown and free-text box, so "RawDevice" + a TagId was a
well-formed row that saved, deployed, and resolved to nothing.
The trees are rooted at the grant's own cluster, so a cross-cluster scope is structurally unreachable
here — the picker's counterpart to the deploy gate's NodeAclScopeCrossCluster rule. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
@inject IAclScopeService Scopes
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Choose scope &middot; <span class="mono">@ClusterId</span></h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3">
Pick the node this grant applies to. Permissions inherit <strong>downward</strong>,
so a grant on a device covers every tag beneath it. Both trees are scoped to this
cluster, so a grant cannot be pointed at another cluster's node.
</p>
@if (_loading)
{
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
}
else
{
<div class="row g-3">
<div class="col-md-6">
<div class="panel-head mb-1">Raw device tree</div>
<div class="border rounded p-2" style="max-height:50vh; overflow:auto">
<AclScopeTree Nodes="_rawHeader" SelectedKey="@_selected?.Key" OnSelect="Select" />
@if (_roots.Raw.Count == 0)
{
<p class="text-muted small mb-0 mt-1">This cluster has no raw tree yet.</p>
}
else
{
<AclScopeTree Nodes="_roots.Raw" SelectedKey="@_selected?.Key"
OnSelect="Select" LoadChildren="LoadChildrenAsync" />
}
</div>
</div>
<div class="col-md-6">
<div class="panel-head mb-1">UNS tree</div>
<div class="border rounded p-2" style="max-height:50vh; overflow:auto">
<AclScopeTree Nodes="_unsHeader" SelectedKey="@_selected?.Key" OnSelect="Select" />
@if (_roots.Uns.Count == 0)
{
<p class="text-muted small mb-0 mt-1">This cluster has no UNS areas yet.</p>
}
else
{
<AclScopeTree Nodes="_roots.Uns" SelectedKey="@_selected?.Key" OnSelect="Select" />
}
</div>
<p class="text-muted small mt-2 mb-0">
UNS tags are not listed: a UNS signal is a <em>reference</em> to a raw
tag, and a <span class="mono">Tag</span> grant already covers the raw
node <em>and</em> every UNS projection of it. Pick the tag in the raw
tree.
</p>
</div>
</div>
}
</div>
<div class="modal-footer d-flex justify-content-between">
<div class="small">
@if (_selected is null)
{
<span class="text-muted">Nothing selected.</span>
}
else
{
<span>
<span class="chip chip-idle me-1">@_selected.ScopeKind</span>
<span class="mono">@_selected.PathLabel</span>
@if (_selected.ScopeId is not null)
{
<span class="text-muted ms-1">(@_selected.ScopeId)</span>
}
</span>
}
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="ConfirmAsync"
disabled="@(_selected is null)">Use this scope</button>
</div>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>The cluster the grant belongs to — both trees are rooted here.</summary>
[Parameter] public string ClusterId { get; set; } = "";
/// <summary>Raised with the chosen scope. The host reads ScopeKind + ScopeId off it.</summary>
[Parameter] public EventCallback<AclScopeNode> OnPicked { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private AclScopeRoots _roots = new(Array.Empty<AclScopeNode>(), Array.Empty<AclScopeNode>());
private AclScopeNode? _selected;
private bool _loading;
private bool _wasVisible;
// Whole-namespace rows. They are real scopes (NodeAclScopeKind.Namespace) with no entity behind them,
// so they are synthesized here rather than coming from a tree.
private readonly IReadOnlyList<AclScopeNode> _rawHeader = new[]
{
new AclScopeNode
{
Key = "ns:raw",
DisplayName = "Every raw node",
Icon = "🌐",
ScopeKind = NodeAclScopeKind.Namespace,
ScopeId = NodeAclRealms.Raw,
PathLabel = "raw namespace",
},
};
private readonly IReadOnlyList<AclScopeNode> _unsHeader = new[]
{
new AclScopeNode
{
Key = "ns:uns",
DisplayName = "Every UNS node",
Icon = "🌐",
ScopeKind = NodeAclScopeKind.Namespace,
ScopeId = NodeAclRealms.Uns,
PathLabel = "uns namespace",
},
};
protected override async Task OnParametersSetAsync()
{
// Reload only on the false→true transition, so a re-render while open does not wipe the
// expansion state the operator has built up.
if (Visible && !_wasVisible)
{
_selected = null;
_loading = true;
_roots = new AclScopeRoots(Array.Empty<AclScopeNode>(), Array.Empty<AclScopeNode>());
StateHasChanged();
_roots = string.IsNullOrWhiteSpace(ClusterId)
? new AclScopeRoots(Array.Empty<AclScopeNode>(), Array.Empty<AclScopeNode>())
: await Scopes.LoadRootsAsync(ClusterId);
// The cluster root is the one node worth opening for the operator — everything else is a
// decision they should make deliberately.
foreach (var root in _roots.Raw) { root.Expanded = false; }
_loading = false;
}
_wasVisible = Visible;
}
private Task<IReadOnlyList<AclScopeNode>> LoadChildrenAsync(AclScopeNode node) =>
Scopes.LoadChildrenAsync(node);
private void Select(AclScopeNode node)
{
_selected = node;
StateHasChanged();
}
private async Task ConfirmAsync()
{
if (_selected is null) { return; }
await OnPicked.InvokeAsync(_selected);
}
private Task CancelAsync() => OnCancel.InvokeAsync();
}
@@ -0,0 +1,118 @@
@* #520 stage 7 — recursive, single-select renderer for the ACL scope picker.
Deliberately a separate component from RawTree rather than a third mode on it. RawTree already carries
the /raw editing tree AND the equipment reference picker; a third mode would put the scope picker's
bugs on the same page as device authoring, and this repo has no bUnit to catch that. This renders the
unified AclScopeNode, so ONE component covers both the raw and the UNS hierarchy. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
@foreach (var node in Nodes)
{
@RenderNode(node, 0)
}
@code {
/// <summary>The rows to render at this level.</summary>
[Parameter, EditorRequired] public IReadOnlyList<AclScopeNode> Nodes { get; set; } = default!;
/// <summary>The <see cref="AclScopeNode.Key"/> of the currently-selected row, if any.</summary>
[Parameter] public string? SelectedKey { get; set; }
/// <summary>Raised when a selectable row is chosen. Single-select: a grant has exactly one scope.</summary>
[Parameter] public EventCallback<AclScopeNode> OnSelect { get; set; }
/// <summary>
/// Loads one lazy level (the raw side). Wired by the host to <c>IAclScopeService.LoadChildrenAsync</c>;
/// when absent, every node is treated as already materialised.
/// </summary>
[Parameter] public Func<AclScopeNode, Task<IReadOnlyList<AclScopeNode>>>? LoadChildren { get; set; }
private RenderFragment RenderNode(AclScopeNode node, int depth) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var hasExpander = node.HasLazyChildren || node.Children.Count > 0;
var selected = node.Selectable && node.Key == SelectedKey;
<div @key="node.Key" class="d-flex align-items-center gap-1 py-1" style="@indent">
@if (hasExpander)
{
<button type="button" class="btn btn-sm btn-link p-0" style="width:18px"
@onclick="@(() => ToggleAsync(node))">
@(node.Expanded ? "▼" : "▶")
</button>
}
else
{
<span style="width:18px"></span>
}
@if (node.Selectable)
{
<button type="button"
class="btn btn-sm p-0 px-1 border-0 d-inline-flex align-items-center gap-1 @(selected ? "bg-primary text-white rounded" : "btn-link text-decoration-none")"
@onclick="@(() => OnSelect.InvokeAsync(node))">
<span aria-hidden="true">@node.Icon</span>
<span class="mono small">@node.DisplayName</span>
<span class="chip chip-idle ms-1" style="font-size:0.65rem">@node.ScopeKind</span>
</button>
}
else
{
@* Label only — an enterprise heading, or a level with no ACL scope kind behind it. *@
<span class="d-inline-flex align-items-center gap-1 text-muted">
<span aria-hidden="true">@node.Icon</span>
<span class="mono small">@node.DisplayName</span>
</span>
}
</div>
@if (node.Expanded && node.Loading)
{
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
}
else if (node.Expanded && node.Error is not null)
{
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">@node.Error</div>
}
else if (node.Expanded)
{
@foreach (var child in node.Children)
{
@RenderNode(child, depth + 1)
}
}
};
private async Task ToggleAsync(AclScopeNode node)
{
if (node.Loading) { return; }
node.Expanded = !node.Expanded;
if (node.Expanded && node.HasLazyChildren && !node.Loaded && LoadChildren is not null)
{
node.Loading = true;
node.Error = null;
StateHasChanged();
try
{
var children = await LoadChildren(node);
node.Children.Clear();
node.Children.AddRange(children);
node.Loaded = true;
}
catch (Exception ex)
{
node.Error = ex.Message;
}
finally
{
node.Loading = false;
}
}
StateHasChanged();
}
}
@@ -72,6 +72,10 @@ public static class EndpointRouteBuilderExtensions
// UnsTreeService's reference/VirtualTag/ScriptedAlarm mutations (WP1); mirrors the
// deploy-time DraftValidator UnsEffectiveNameCollision rule.
services.AddScoped<IEffectiveNameGuard, EffectiveNameGuard>();
// #520 stage 7: the ACL scope picker's two trees + the authoring-time scope/principal checks that
// mirror the deploy gate's NodeAclScope* rules. Scoped, because it composes the two scoped tree
// services above.
services.AddScoped<Acls.IAclScopeService, Acls.AclScopeService>();
// WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
services.AddScoped<RawTagCsvExportReader>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
@@ -7,6 +7,7 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.JSInterop
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Acls
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout
@using ZB.MOM.WW.OtOpcUa.Security.Auth
@using ZB.MOM.WW.Theme
@@ -0,0 +1,379 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Acls;
/// <summary>
/// #520 stage 7 — the ACL scope picker's trees and the authoring-time checks behind them.
/// </summary>
/// <remarks>
/// The defect this stage closes is not a crash: it is that a grant could be authored, saved, deployed
/// and sealed green while resolving to nothing. So the tests that matter are the ones asserting a
/// <em>rejection</em> — and each is paired with the neighbouring case that must still be accepted, or
/// a service that rejected everything would pass the lot.
/// </remarks>
public sealed class AclScopeServiceTests
{
// --- The picker's trees ------------------------------------------------------------------------
/// <summary>
/// The raw tree is rooted at the cluster and every level offers the scope kind that level grants
/// at. Without the kinds the picker would be a browser, not a picker.
/// </summary>
[Fact]
public async Task The_raw_tree_carries_a_scope_kind_at_every_level()
{
var svc = AclScopeTestDb.NewService();
var roots = await svc.LoadRootsAsync(AclScopeTestDb.ClusterId);
var cluster = roots.Raw.ShouldHaveSingleItem();
cluster.ScopeKind.ShouldBe(NodeAclScopeKind.Cluster);
var folder = (await svc.LoadChildrenAsync(cluster)).ShouldHaveSingleItem();
folder.ScopeKind.ShouldBe(NodeAclScopeKind.RawFolder);
folder.ScopeId.ShouldBe(AclScopeTestDb.FolderId);
var driver = (await svc.LoadChildrenAsync(folder)).ShouldHaveSingleItem();
driver.ScopeKind.ShouldBe(NodeAclScopeKind.RawDriver);
driver.ScopeId.ShouldBe(AclScopeTestDb.DriverId);
var device = (await svc.LoadChildrenAsync(driver)).ShouldHaveSingleItem();
device.ScopeKind.ShouldBe(NodeAclScopeKind.RawDevice);
device.ScopeId.ShouldBe(AclScopeTestDb.DeviceId);
var group = (await svc.LoadChildrenAsync(device)).ShouldHaveSingleItem();
group.ScopeKind.ShouldBe(NodeAclScopeKind.RawTagGroup);
group.ScopeId.ShouldBe(AclScopeTestDb.TagGroupId);
var tag = (await svc.LoadChildrenAsync(group)).ShouldHaveSingleItem();
tag.ScopeKind.ShouldBe(NodeAclScopeKind.Tag);
tag.ScopeId.ShouldBe(AclScopeTestDb.TagId);
}
/// <summary>
/// A cluster row produces NO scope id. The deploy gate rejects a Cluster grant that carries one
/// (<c>NodeAclScopeIdUnexpected</c>), so a picker that set the cluster id there would author a row
/// that cannot be deployed.
/// </summary>
[Fact]
public async Task The_cluster_row_produces_no_scope_id()
{
var svc = AclScopeTestDb.NewService();
var cluster = (await svc.LoadRootsAsync(AclScopeTestDb.ClusterId)).Raw.ShouldHaveSingleItem();
cluster.ScopeId.ShouldBeNull();
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, cluster.ScopeKind!.Value, cluster.ScopeId))
.Ok.ShouldBeTrue();
}
/// <summary>
/// Another cluster's nodes are structurally unreachable through the picker — the counterpart of the
/// deploy gate's cross-cluster rule. Reachability, not just discouragement, is the point: the SIDE
/// driver exists and resolves, so only the rooting keeps it out.
/// </summary>
[Fact]
public async Task The_picker_cannot_reach_another_clusters_nodes()
{
var svc = AclScopeTestDb.NewService();
var roots = await svc.LoadRootsAsync(AclScopeTestDb.ClusterId);
var reachable = await FlattenAsync(svc, roots);
reachable.ShouldNotContain(AclScopeTestDb.OtherDriverId);
// …and it is a real driver, so the absence is the rooting rather than the id being unknown.
var side = await svc.LoadRootsAsync(AclScopeTestDb.OtherClusterId);
(await FlattenAsync(svc, side)).ShouldContain(AclScopeTestDb.OtherDriverId);
}
/// <summary>The UNS side offers area, line and equipment.</summary>
[Fact]
public async Task The_uns_tree_offers_area_line_and_equipment()
{
var svc = AclScopeTestDb.NewService();
var area = (await svc.LoadRootsAsync(AclScopeTestDb.ClusterId)).Uns.ShouldHaveSingleItem();
area.ScopeKind.ShouldBe(NodeAclScopeKind.UnsArea);
area.ScopeId.ShouldBe(AclScopeTestDb.AreaId);
var line = area.Children.ShouldHaveSingleItem();
line.ScopeKind.ShouldBe(NodeAclScopeKind.UnsLine);
line.ScopeId.ShouldBe(AclScopeTestDb.LineId);
var equipment = line.Children.ShouldHaveSingleItem();
equipment.ScopeKind.ShouldBe(NodeAclScopeKind.Equipment);
equipment.ScopeId.ShouldBe(AclScopeTestDb.EquipmentId);
}
/// <summary>
/// An unknown cluster yields empty trees rather than the whole fleet. A picker that fell back to
/// everything would hand an operator another cluster's nodes at the one moment they are least
/// likely to notice.
/// </summary>
[Fact]
public async Task An_unknown_cluster_yields_empty_trees()
{
var svc = AclScopeTestDb.NewService();
var roots = await svc.LoadRootsAsync("NO-SUCH-CLUSTER");
roots.Raw.ShouldBeEmpty();
roots.Uns.ShouldBeEmpty();
}
// --- The authoring-time scope check ------------------------------------------------------------
/// <summary>
/// THE test for this stage. Every scope kind resolves when pointed at its own entity type, and the
/// SAME id under a neighbouring kind does not — which is precisely the row the old free-text field
/// could produce, and which saved, deployed and authorized nobody.
/// </summary>
[Theory]
[InlineData(NodeAclScopeKind.RawFolder, AclScopeTestDb.FolderId)]
[InlineData(NodeAclScopeKind.RawDriver, AclScopeTestDb.DriverId)]
[InlineData(NodeAclScopeKind.RawDevice, AclScopeTestDb.DeviceId)]
[InlineData(NodeAclScopeKind.RawTagGroup, AclScopeTestDb.TagGroupId)]
[InlineData(NodeAclScopeKind.Tag, AclScopeTestDb.TagId)]
[InlineData(NodeAclScopeKind.UnsArea, AclScopeTestDb.AreaId)]
[InlineData(NodeAclScopeKind.UnsLine, AclScopeTestDb.LineId)]
[InlineData(NodeAclScopeKind.Equipment, AclScopeTestDb.EquipmentId)]
public async Task A_kind_resolves_its_own_entity_and_nothing_else(NodeAclScopeKind kind, string id)
{
var svc = AclScopeTestDb.NewService();
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, kind, id))
.Ok.ShouldBeTrue($"{kind} '{id}' names a real entity of that type");
// The same id under a different kind must not resolve. RawDevice is the neighbour for every raw
// kind and UnsArea for every UNS one; both are real kinds with real rows, so a service that
// ignored the kind entirely would pass the line above and fail here.
var neighbour = kind is NodeAclScopeKind.UnsArea or NodeAclScopeKind.UnsLine or NodeAclScopeKind.Equipment
? (kind == NodeAclScopeKind.UnsArea ? NodeAclScopeKind.UnsLine : NodeAclScopeKind.UnsArea)
: (kind == NodeAclScopeKind.RawDevice ? NodeAclScopeKind.RawDriver : NodeAclScopeKind.RawDevice);
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, neighbour, id))
.Ok.ShouldBeFalse($"'{id}' is not a {neighbour}");
}
/// <summary>An id that names nothing is rejected, and the message says so.</summary>
[Fact]
public async Task A_scope_id_that_names_nothing_is_rejected()
{
var svc = AclScopeTestDb.NewService();
var check = await svc.CheckScopeAsync(
AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDevice, "DEV-typo");
check.Ok.ShouldBeFalse();
check.Message.ShouldContain("DEV-typo");
}
/// <summary>
/// A real id in ANOTHER cluster is rejected, and the message names the cluster that owns it. This
/// is the one rejection whose id exists, so a check that only asked "does a row with this id
/// exist?" would accept it and confer authority across a cluster boundary.
/// </summary>
[Fact]
public async Task A_scope_in_another_cluster_is_rejected_and_names_its_owner()
{
var svc = AclScopeTestDb.NewService();
var check = await svc.CheckScopeAsync(
AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDriver, AclScopeTestDb.OtherDriverId);
check.Ok.ShouldBeFalse();
check.Message.ShouldContain(AclScopeTestDb.OtherClusterId);
// Authored under its own cluster, the same row is fine — so the rejection is the boundary, not
// the driver.
(await svc.CheckScopeAsync(
AclScopeTestDb.OtherClusterId, NodeAclScopeKind.RawDriver, AclScopeTestDb.OtherDriverId))
.Ok.ShouldBeTrue();
}
/// <summary>
/// A row whose parent chain is broken exists but is owned by no cluster, so it resolves to no path.
/// Checked separately from a missing row because the two reach the rejection by different routes.
/// </summary>
[Fact]
public async Task An_entity_whose_parent_chain_is_broken_is_rejected()
{
var svc = AclScopeTestDb.NewService();
(await svc.CheckScopeAsync(
AclScopeTestDb.ClusterId, NodeAclScopeKind.Equipment, AclScopeTestDb.OrphanEquipmentId))
.Ok.ShouldBeFalse();
// The healthy equipment in the same table passes, so the rejection is the broken line and not the
// Equipment arm being wired to nothing.
(await svc.CheckScopeAsync(
AclScopeTestDb.ClusterId, NodeAclScopeKind.Equipment, AclScopeTestDb.EquipmentId))
.Ok.ShouldBeTrue();
}
/// <summary>
/// A Cluster grant covers everything and carries no id; one that carries an id means the author
/// picked the wrong kind and the grant is BROADER than intended — the direction that must never
/// pass silently.
/// </summary>
[Fact]
public async Task A_cluster_grant_carrying_a_scope_id_is_rejected()
{
var svc = AclScopeTestDb.NewService();
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.Cluster, AclScopeTestDb.DeviceId))
.Ok.ShouldBeFalse();
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.Cluster, null))
.Ok.ShouldBeTrue();
}
/// <summary>Every kind but Cluster needs a target.</summary>
[Fact]
public async Task A_narrower_kind_without_a_scope_id_is_rejected()
{
var svc = AclScopeTestDb.NewService();
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDevice, null))
.Ok.ShouldBeFalse();
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDevice, " "))
.Ok.ShouldBeFalse();
}
/// <summary>
/// The retired v2 kind is rejected whatever its target — including a target that would resolve
/// under the kind that replaced it, which is the shape an operator migrating a v2 row produces.
/// </summary>
[Fact]
public async Task The_retired_FolderSegment_kind_is_rejected()
{
var svc = AclScopeTestDb.NewService();
var check = await svc.CheckScopeAsync(
AclScopeTestDb.ClusterId, NodeAclScopeKind.FolderSegment, AclScopeTestDb.FolderId);
check.Ok.ShouldBeFalse();
check.Message.ShouldContain("RawFolder");
}
/// <summary>A Namespace grant takes a realm token, in either spelling, and nothing else.</summary>
[Theory]
[InlineData("raw", true)]
[InlineData("uns", true)]
[InlineData("RAW", true)]
[InlineData("https://zb.com/otopcua/uns", true)]
[InlineData("ns=2", false)]
[InlineData("MAIN", false)]
public async Task A_namespace_grant_accepts_only_a_realm(string scopeId, bool expected)
{
var svc = AclScopeTestDb.NewService();
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.Namespace, scopeId))
.Ok.ShouldBe(expected);
}
/// <summary>An ordinal outside the enum comes from a hand-edited row; nothing below it is meaningful.</summary>
[Fact]
public async Task An_undefined_scope_kind_is_rejected()
{
var svc = AclScopeTestDb.NewService();
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, (NodeAclScopeKind)999, "anything"))
.Ok.ShouldBeFalse();
}
// --- Principals ---------------------------------------------------------------------------------
/// <summary>The reserved anonymous principal always reaches something — it has no key to count.</summary>
[Fact]
public async Task The_anonymous_principal_is_recognised_without_a_key()
{
var svc = AclScopeTestDb.NewService();
var usage = await svc.CheckPrincipalAsync(OpcUaPrincipals.Anonymous);
usage.IsAnonymous.ShouldBeTrue();
usage.ReachesSomething.ShouldBeTrue();
}
/// <summary>
/// Only enabled, unexpired keys count as reaching the principal. An expired key is the case a
/// count of rows would get wrong, so the fixture's stale principal has one and nothing else.
/// </summary>
[Fact]
public async Task Only_live_keys_count_towards_a_principal()
{
var svc = AclScopeTestDb.NewService(new FixedClock(AclScopeTestDb.Now));
var live = await svc.CheckPrincipalAsync(AclScopeTestDb.LivePrincipal);
live.EnabledKeys.ShouldBe(2);
live.DisabledKeys.ShouldBe(1);
live.ReachesSomething.ShouldBeTrue();
var stale = await svc.CheckPrincipalAsync(AclScopeTestDb.StalePrincipal);
stale.EnabledKeys.ShouldBe(0, "its only key expired yesterday");
stale.DisabledKeys.ShouldBe(1);
stale.ReachesSomething.ShouldBeFalse();
}
/// <summary>A principal nothing uses reaches nobody — the typo case the hint exists to surface.</summary>
[Fact]
public async Task An_unknown_principal_reaches_nobody()
{
var svc = AclScopeTestDb.NewService();
var usage = await svc.CheckPrincipalAsync("scadabridge-dcI"); // capital i, not lowercase L
usage.EnabledKeys.ShouldBe(0);
usage.ReachesSomething.ShouldBeFalse();
}
/// <summary>The suggestion list covers key principals and the reserved anonymous one.</summary>
[Fact]
public async Task Known_principals_include_every_key_principal_and_anonymous()
{
var svc = AclScopeTestDb.NewService();
var known = await svc.LoadKnownPrincipalsAsync();
known.ShouldContain(AclScopeTestDb.LivePrincipal);
known.ShouldContain(AclScopeTestDb.StalePrincipal);
known.ShouldContain(OpcUaPrincipals.Anonymous);
known.Count.ShouldBe(known.Distinct(StringComparer.OrdinalIgnoreCase).Count(), "three keys share a principal");
}
// --- Helpers ------------------------------------------------------------------------------------
/// <summary>
/// A clock pinned to the fixture's <c>Now</c>. Key expiry is the one thing here that moves with the
/// wall clock, and a test that let it would go red on its own a day after the fixture was written.
/// </summary>
private sealed class FixedClock(DateTime utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(utcNow, TimeSpan.Zero);
}
/// <summary>Every scope id reachable through the picker for one cluster, both trees, fully expanded.</summary>
private static async Task<List<string>> FlattenAsync(IAclScopeService svc, AclScopeRoots roots)
{
var ids = new List<string>();
async Task WalkAsync(AclScopeNode node)
{
if (node.ScopeId is not null) ids.Add(node.ScopeId);
var children = node.HasLazyChildren
? await svc.LoadChildrenAsync(node)
: node.Children;
foreach (var child in children) await WalkAsync(child);
}
foreach (var node in roots.Raw.Concat(roots.Uns)) await WalkAsync(node);
return ids;
}
}
@@ -0,0 +1,143 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Acls;
/// <summary>
/// Fixture for <see cref="AclScopeService"/> — two clusters, so cross-cluster scoping is testable, and
/// one deliberately broken UNS chain, so "the row exists but no cluster owns it" is testable
/// separately from "the row does not exist".
/// </summary>
internal static class AclScopeTestDb
{
public const string ClusterId = "MAIN";
public const string OtherClusterId = "SIDE";
public const string FolderId = "RF-plcs";
public const string DriverId = "DRV-modbus";
public const string DeviceId = "DEV-1";
public const string TagGroupId = "TG-motors";
public const string TagId = "TAG-speed";
public const string AreaId = "AREA-1";
public const string LineId = "LINE-1";
public const string EquipmentId = "EQ-1";
/// <summary>An equipment whose line does not exist — present, but owned by no cluster.</summary>
public const string OrphanEquipmentId = "EQ-orphan";
/// <summary>A driver in the OTHER cluster, for the cross-cluster rule.</summary>
public const string OtherDriverId = "DRV-side";
public const string LivePrincipal = "scadabridge-dcl";
public const string StalePrincipal = "retired-consumer";
/// <summary>The clock the fixture's key expiries are expressed against.</summary>
public static readonly DateTime Now = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
public static OtOpcUaConfigDbContext CreateNamed(string name) =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
public static IDbContextFactory<OtOpcUaConfigDbContext> Factory(string name) => new NamedFactory(name);
/// <summary>Seeds a fresh uniquely-named InMemory database and returns a service over it.</summary>
public static AclScopeService NewService(TimeProvider? time = null)
{
var name = $"acl-{Guid.NewGuid():N}";
using (var db = CreateNamed(name)) { Seed(db); }
var factory = Factory(name);
return new AclScopeService(factory, new RawTreeService(factory), new UnsTreeService(factory), time);
}
public static void Seed(OtOpcUaConfigDbContext db)
{
db.ServerClusters.Add(new ServerCluster
{
ClusterId = ClusterId, Name = "Main", Enterprise = "zb", Site = "warsaw-west",
RedundancyMode = RedundancyMode.None, CreatedBy = "test",
});
db.ServerClusters.Add(new ServerCluster
{
ClusterId = OtherClusterId, Name = "Side", Enterprise = "zb", Site = "krakow",
RedundancyMode = RedundancyMode.None, CreatedBy = "test",
});
// MAIN raw: folder → driver → device → group → tag.
db.RawFolders.Add(new RawFolder
{
RawFolderId = FolderId, ClusterId = ClusterId, ParentRawFolderId = null, Name = "PLCs",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = DriverId, ClusterId = ClusterId, RawFolderId = FolderId,
Name = "modbus-1", DriverType = "Modbus", DriverConfig = "{}",
});
db.Devices.Add(new Device
{
DeviceId = DeviceId, DriverInstanceId = DriverId, Name = "dev1", DeviceConfig = "{}",
});
db.TagGroups.Add(new TagGroup
{
TagGroupId = TagGroupId, DeviceId = DeviceId, ParentTagGroupId = null, Name = "Motors",
});
db.Tags.Add(new Tag
{
TagId = TagId, DeviceId = DeviceId, TagGroupId = TagGroupId, Name = "speed",
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}",
});
// SIDE raw: one driver, so a cross-cluster scope has something real to point at.
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = OtherDriverId, ClusterId = OtherClusterId, RawFolderId = null,
Name = "s7-side", DriverType = "S7", DriverConfig = "{}",
});
// MAIN UNS: area → line → equipment.
db.UnsAreas.Add(new UnsArea { UnsAreaId = AreaId, ClusterId = ClusterId, Name = "Packaging" });
db.UnsLines.Add(new UnsLine { UnsLineId = LineId, UnsAreaId = AreaId, Name = "Line 1" });
db.Equipment.Add(new Equipment
{
EquipmentId = EquipmentId, EquipmentUuid = Guid.NewGuid(), UnsLineId = LineId,
Name = "packer-1", MachineCode = "packer_001",
});
// An equipment whose line was deleted: it exists, but nothing owns it.
db.Equipment.Add(new Equipment
{
EquipmentId = OrphanEquipmentId, EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-GONE",
Name = "stranded", MachineCode = "stranded_001",
});
db.ApiKeys.Add(Key("k1", LivePrincipal, enabled: true, expires: null));
db.ApiKeys.Add(Key("k2", LivePrincipal, enabled: true, expires: null));
db.ApiKeys.Add(Key("k3", LivePrincipal, enabled: false, expires: null));
db.ApiKeys.Add(Key("k4", StalePrincipal, enabled: true, expires: Now.AddDays(-1)));
db.SaveChanges();
}
private static ApiKey Key(string id, string principal, bool enabled, DateTime? expires) => new()
{
ApiKeyId = id,
Name = id,
SecretHash = "not-a-secret",
Principal = principal,
Enabled = enabled,
CreatedUtc = Now.AddDays(-30),
ExpiresUtc = expires,
};
private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() => CreateNamed(name);
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CreateNamed(name));
}
}