From d542ab5b7c97e3000b76629f077862039860fede Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 1 Aug 2026 10:53:31 -0400 Subject: [PATCH] feat(security): author ACL grants from the real tree, not free text (#520 stage 7) The last stage of #520. Enforcement landed in stages 5-6; this closes the authoring end, where a grant could be saved, deployed and sealed green while resolving to nothing. Scope kind and scope target are now chosen TOGETHER from a picker over the cluster's raw and UNS trees (AclScopePickerModal -> AclScopeTree, backed by IAclScopeService). They were previously an unrelated dropdown and a free-text box, so "RawDevice + a TagId" was a well-formed row. Because the trees are rooted at the grant's own cluster and each node supplies its own kind, NodeAclScopeCrossCluster, NodeAclScopeIdUnexpected, NodeAclScopeIdMissing and NodeAclScopeKindRetired all become unauthorable by construction. CheckScopeAsync mirrors DraftValidator.ValidateNodeAclScopes rule for rule and blocks the save when a scope resolves to nothing - re-checked on submit, not trusted from form load, because a row whose entity was deleted since is exactly where the operator never touches the picker. The list page marks such rows unresolved with the reason. The principal field suggests known principals and reports how many live keys reach the one typed; reaching nobody warns rather than blocks, since authoring ahead of minting is legitimate. Deliberate departures from the plan line, all recorded in the design doc: - The banners are reshaped, not removed. "Not enforced" stopped being true at stage 6, but enforcement is still a per-node flag the AdminUI cannot see, so a narrow grant really does confer nothing on a default-configured node. - A Cluster row produces a NULL ScopeId. The deploy gate rejects one that carries an id, so filling in the cluster id would author undeployable rows. - AclScopeTree is a new component rather than a third mode on RawTree, which already carries the /raw editing tree and the equipment reference picker. - UNS tags are not offered: a UNS signal is a UnsTagReference, and a Tag grant already covers the raw node and every UNS projection of it. Also fixed: the ACL editor had a DataAnnotationsValidator and no ValidationSummary, so an invalid NodeAclId made Create a silent no-op. Tests: 30 in AdminUI.Tests/Acls, falsified with six production mutations - each turned exactly the intended tests red and no others. Live gate PASSED on docker-dev, 11 legs; both central containers asserted on the same image by container id. The pair that matters is legs 8 and 11: the page and the deploy gate reject the same row with the same reason. Legs 5 and 9 are the positive controls - without them the gate passes against a page that refuses everything. Not covered: UNS-realm scope selection (SITE-A has no UNS areas). Rig restored to zero ACL rows. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- CLAUDE.md | 17 +- ...026-07-31-apikey-acl-enforcement-design.md | 77 +++- docs/security.md | 72 +++- .../Acls/AclScopeNode.cs | 111 +++++ .../Acls/AclScopeService.cs | 363 +++++++++++++++++ .../Acls/IAclScopeService.cs | 72 ++++ .../Components/Pages/Clusters/AclEdit.razor | 177 +++++--- .../Pages/Clusters/ClusterAcls.razor | 76 +++- .../Shared/Acls/AclScopePickerModal.razor | 190 +++++++++ .../Components/Shared/Acls/AclScopeTree.razor | 118 ++++++ .../EndpointRouteBuilderExtensions.cs | 4 + .../ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor | 1 + .../Acls/AclScopeServiceTests.cs | 379 ++++++++++++++++++ .../Acls/AclScopeTestDb.cs | 143 +++++++ 14 files changed, 1703 insertions(+), 97 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeNode.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeService.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/IAclScopeService.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Acls/AclScopePickerModal.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Acls/AclScopeTree.razor create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Acls/AclScopeServiceTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Acls/AclScopeTestDb.cs diff --git a/CLAUDE.md b/CLAUDE.md index fe6087a6..b17bf8ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.) diff --git a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md index 20a9742d..ccbeae80 100644 --- a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md +++ b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md @@ -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 diff --git a/docs/security.md b/docs/security.md index d5920caa..c173ace7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeNode.cs new file mode 100644 index 00000000..a4d3a357 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeNode.cs @@ -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; + +/// +/// One row in the ACL scope picker (#520 stage 7) — a place a NodeAcl grant can be scoped at, +/// in either the raw device tree or the UNS tree. +/// +/// +/// +/// Why a third tree view-model rather than reusing . The picker's job is +/// to produce a and a ScopeId together, from both +/// hierarchies, in one list. covers only the raw side and +/// 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 . +/// +/// +/// A node with a null is a label only (the enterprise/cluster heading rows, +/// and UNS tag rows — see for why those are deliberately not +/// selectable). +/// +/// +public sealed class AclScopeNode +{ + /// Gets the stable per-node key, unique within the picker tree. + public required string Key { get; init; } + + /// Gets the label shown in the tree. + public required string DisplayName { get; init; } + + /// Gets the leading glyph, matching the icon vocabulary of the /raw and /uns trees. + public required string Icon { get; init; } + + /// + /// Gets the scope kind this row grants at, or when the row is a label that + /// cannot be selected. + /// + public NodeAclScopeKind? ScopeKind { get; init; } + + /// + /// Gets the NodeAcl.ScopeId this row produces. Null for a + /// row, which covers its whole cluster and carries no id, and for a label row. + /// + public string? ScopeId { get; init; } + + /// Gets the ancestry label shown once selected, e.g. Plant / Modbus / dev1. + public required string PathLabel { get; init; } + + /// Gets a value indicating whether this node's children load on first expand. + public bool HasLazyChildren { get; init; } + + /// Gets the eagerly-materialised children (the whole UNS side; the raw side's roots only). + public List Children { get; } = new(); + + /// + /// Gets the raw-tree node this row projects, when there is one. The picker hands it back to + /// to expand the next level; null on the UNS side, + /// which is loaded whole. + /// + public RawNode? RawSource { get; init; } + + // --- Transient UI state (never persisted) --- + + /// Gets or sets a value indicating whether the node is expanded. + public bool Expanded { get; set; } + + /// Gets or sets a value indicating whether the lazy children have been loaded. + public bool Loaded { get; set; } + + /// Gets or sets a value indicating whether a lazy load is in flight. + public bool Loading { get; set; } + + /// Gets or sets the last load error, if any. + public string? Error { get; set; } + + /// Gets a value indicating whether this row can be chosen as a grant scope. + public bool Selectable => ScopeKind is not null; +} + +/// The two scope trees for one cluster, as the picker shows them. +/// The raw device tree, rooted at the cluster; empty when the cluster is unknown. +/// The UNS tree (area → line → equipment) for the same cluster. +public readonly record struct AclScopeRoots( + IReadOnlyList Raw, + IReadOnlyList Uns); + +/// +/// The outcome of resolving one authored (ScopeKind, ScopeId) pair against the current +/// configuration — the authoring-time mirror of the deploy gate's NodeAclScope* rules. +/// +/// Whether the scope names something the grant can actually be evaluated against. +/// +/// What it resolves to when , or what is wrong and how to fix it when not. +/// +public readonly record struct AclScopeCheck(bool Ok, string Message); + +/// How many API keys a principal name currently reaches. +/// Whether this is the reserved anonymous principal, which has no key. +/// Enabled, unexpired keys authenticating as this principal. +/// Keys naming this principal that are disabled or already expired. +public readonly record struct PrincipalUsage(bool IsAnonymous, int EnabledKeys, int DisabledKeys) +{ + /// + /// 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. + /// + public bool ReachesSomething => IsAnonymous || EnabledKeys > 0; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeService.cs new file mode 100644 index 00000000..8a9ea8b2 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/AclScopeService.cs @@ -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; + +/// EF-backed . +/// +/// The two trees are projected from the services that already own them — +/// for the raw side (keeping its lazy expansion) and +/// for the UNS side (which loads whole) — so the picker cannot show a +/// hierarchy that disagrees with /raw or /uns. Only and the +/// principal lookups go to the database directly. +/// +public sealed class AclScopeService : IAclScopeService +{ + private readonly IDbContextFactory _dbFactory; + private readonly IRawTreeService _raw; + private readonly IUnsTreeService _uns; + private readonly TimeProvider _time; + + /// Initializes a new instance of the class. + /// The config DB context factory. + /// The raw project-tree service. + /// The UNS tree service. + /// Clock used to decide whether a key has already expired. + public AclScopeService( + IDbContextFactory dbFactory, + IRawTreeService raw, + IUnsTreeService uns, + TimeProvider? time = null) + { + _dbFactory = dbFactory; + _raw = raw; + _uns = uns; + _time = time ?? TimeProvider.System; + } + + /// + public async Task 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() + : new[] { ProjectRaw(rawCluster, parentPath: null) }; + + var uns = unsCluster is null + ? Array.Empty() + : ProjectUnsChildren(unsCluster, parentPath: null); + + return new AclScopeRoots(raw, uns); + } + + /// + public async Task> LoadChildrenAsync( + AclScopeNode parent, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(parent); + if (parent.RawSource is null) return Array.Empty(); + + var children = await _raw.LoadChildrenAsync(parent.RawSource, ct).ConfigureAwait(false); + return children.Select(c => ProjectRaw(c, parent.PathLabel)).ToList(); + } + + /// + public async Task 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}'."); + } + + /// + public async Task> 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(); + } + + /// + public async Task 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 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 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); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/IAclScopeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/IAclScopeService.cs new file mode 100644 index 00000000..4f622338 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Acls/IAclScopeService.cs @@ -0,0 +1,72 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls; + +/// +/// 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. +/// +/// +/// +/// A service rather than logic in the Razor pages, because this repo has no bUnit — 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 +/// IApiKeyAdminService carries. +/// +/// +/// The picker chooses the kind and the id together. Before this, ScopeKind was a +/// dropdown and ScopeId a free-text box, so the two could disagree — a +/// 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. is what catches the rows that already exist, or that were +/// typed in before the picker was reached. +/// +/// +public interface IAclScopeService +{ + /// + /// 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 NodeAclScopeCrossCluster rule. + /// + /// The cluster the grant belongs to. + /// The cancellation token. + /// The two trees; either may be empty when the cluster has nothing of that kind yet. + Task LoadRootsAsync(string clusterId, CancellationToken ct = default); + + /// + /// Loads one lazy level beneath a raw-tree node. UNS nodes are loaded whole by + /// and never reach here. + /// + /// The node being expanded. + /// The cancellation token. + /// The child rows, or empty for a leaf. + Task> LoadChildrenAsync(AclScopeNode parent, CancellationToken ct = default); + + /// + /// Resolves an authored scope against the current configuration, mirroring the deploy gate's + /// NodeAclScope* rules so the page rejects what the deployment would reject. + /// + /// The cluster the grant belongs to. + /// The authored scope kind. + /// The authored scope id; null or blank for a cluster-wide grant. + /// The cancellation token. + /// What the scope resolves to, or why it does not. + Task CheckScopeAsync( + string clusterId, NodeAclScopeKind kind, string? scopeId, CancellationToken ct = default); + + /// + /// Lists the principal names worth suggesting: every principal an API key authenticates as, every + /// principal an existing grant already names, and the reserved anonymous principal. + /// + /// The cancellation token. + /// Distinct principal names, ordered. + Task> LoadKnownPrincipalsAsync(CancellationToken ct = default); + + /// Reports how many API keys currently authenticate as a principal. + /// The principal name as authored. + /// The cancellation token. + /// The usage counts. + Task CheckPrincipalAsync(string principal, CancellationToken ct = default); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor index 487fc605..47928dde 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor @@ -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 DbFactory +@inject IAclScopeService Scopes @inject NavigationManager Nav
@@ -28,47 +31,35 @@ else if (!IsNew && _existing is null) } else { - -
-
- - - - - - - - - - - - - - -
-
- - + + + @foreach (var p in _knownPrincipals) + { + + } +
- Free text, no picker — a typo saves a grant that resolves to nothing. - Cluster → leave blank · - Namespace → raw or - uns · - UnsArea → a UnsAreaId · - UnsLine → a UnsLineId · - Equipment → an EquipmentId · - Tag → a TagId (a raw tag; the grant covers the raw - node and every UNS projection of it) · - RawFolder → a RawFolderId · - RawDriver → a DriverInstanceId · - RawDevice → a DeviceId · - RawTagGroup → a TagGroupId · - FolderSegment → retired v2 kind, resolves nothing. + @if (string.IsNullOrWhiteSpace(_form.Principal)) + { + + An API key's principal, or the reserved + @OpcUaPrincipals.Anonymous principal that an + anonymous session authenticates as. Mint keys on + /apikeys. + + } + else if (_principal.IsAnonymous) + { + + ✓ The reserved anonymous principal — every session admitted without a + key authenticates as this. + + } + else if (_principal.EnabledKeys > 0) + { + + ✓ @_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. + + } + else + { + + ⚠ 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. + + }
+ +
+ +
+ @_form.ScopeKind + @(string.IsNullOrEmpty(_form.ScopeId) ? "(whole cluster)" : _form.ScopeId) + +
+
+ @(_scope.Ok ? "✓ " : "✗ ")@_scope.Message +
+
+ 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. +
+
+
@@ -162,6 +175,9 @@ else @if (!IsNew) { }
+ + } @code { @@ -183,6 +199,11 @@ else private bool _loaded; private bool _busy; private string? _error; + private bool _pickerVisible; + + private IReadOnlyList _knownPrincipals = Array.Empty(); + 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) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor index f8bac332..365fb3ac 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor @@ -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 DbFactory +@inject IAclScopeService Scopes

ACLs · @ClusterId

@@ -19,24 +21,12 @@ } else { -