merge: ACL scope picker + authoring-time resolution (#520 stage 7)
Closes #520 — all eight stages done and live-gated. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -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
@@ -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_<keyId>_<secret></span>. Mint keys on the
|
||||
<span class="mono">/apikeys</span> page; LDAP now covers AdminUI login only.
|
||||
See <span class="mono">docs/security.md</span>.
|
||||
<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 · <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)
|
||||
|
||||
+190
@@ -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 · <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…
|
||||
</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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user