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 { -