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
This commit is contained in:
Joseph Doherty
2026-08-01 10:53:31 -04:00
parent d4389f57a2
commit d542ab5b7c
14 changed files with 1703 additions and 97 deletions
@@ -0,0 +1,111 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
/// <summary>
/// One row in the ACL scope picker (#520 stage 7) — a place a <c>NodeAcl</c> grant can be scoped at,
/// in either the raw device tree or the UNS tree.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a third tree view-model rather than reusing <see cref="RawNode"/>.</b> The picker's job is
/// to produce a <see cref="NodeAclScopeKind"/> and a <c>ScopeId</c> <i>together</i>, from both
/// hierarchies, in one list. <see cref="RawNode"/> covers only the raw side and <see cref="UnsNode"/>
/// only the UNS side, and neither carries a scope kind — so a shared shape is what lets one renderer
/// and one selection model serve both. The raw side keeps its lazy loading by carrying its source
/// node in <see cref="RawSource"/>.
/// </para>
/// <para>
/// A node with a null <see cref="ScopeKind"/> is a label only (the enterprise/cluster heading rows,
/// and UNS tag rows — see <see cref="IAclScopeService"/> for why those are deliberately not
/// selectable).
/// </para>
/// </remarks>
public sealed class AclScopeNode
{
/// <summary>Gets the stable per-node key, unique within the picker tree.</summary>
public required string Key { get; init; }
/// <summary>Gets the label shown in the tree.</summary>
public required string DisplayName { get; init; }
/// <summary>Gets the leading glyph, matching the icon vocabulary of the /raw and /uns trees.</summary>
public required string Icon { get; init; }
/// <summary>
/// Gets the scope kind this row grants at, or <see langword="null"/> when the row is a label that
/// cannot be selected.
/// </summary>
public NodeAclScopeKind? ScopeKind { get; init; }
/// <summary>
/// Gets the <c>NodeAcl.ScopeId</c> this row produces. Null for a <see cref="NodeAclScopeKind.Cluster"/>
/// row, which covers its whole cluster and carries no id, and for a label row.
/// </summary>
public string? ScopeId { get; init; }
/// <summary>Gets the ancestry label shown once selected, e.g. <c>Plant / Modbus / dev1</c>.</summary>
public required string PathLabel { get; init; }
/// <summary>Gets a value indicating whether this node's children load on first expand.</summary>
public bool HasLazyChildren { get; init; }
/// <summary>Gets the eagerly-materialised children (the whole UNS side; the raw side's roots only).</summary>
public List<AclScopeNode> Children { get; } = new();
/// <summary>
/// Gets the raw-tree node this row projects, when there is one. The picker hands it back to
/// <see cref="IAclScopeService.LoadChildrenAsync"/> to expand the next level; null on the UNS side,
/// which is loaded whole.
/// </summary>
public RawNode? RawSource { get; init; }
// --- Transient UI state (never persisted) ---
/// <summary>Gets or sets a value indicating whether the node is expanded.</summary>
public bool Expanded { get; set; }
/// <summary>Gets or sets a value indicating whether the lazy children have been loaded.</summary>
public bool Loaded { get; set; }
/// <summary>Gets or sets a value indicating whether a lazy load is in flight.</summary>
public bool Loading { get; set; }
/// <summary>Gets or sets the last load error, if any.</summary>
public string? Error { get; set; }
/// <summary>Gets a value indicating whether this row can be chosen as a grant scope.</summary>
public bool Selectable => ScopeKind is not null;
}
/// <summary>The two scope trees for one cluster, as the picker shows them.</summary>
/// <param name="Raw">The raw device tree, rooted at the cluster; empty when the cluster is unknown.</param>
/// <param name="Uns">The UNS tree (area → line → equipment) for the same cluster.</param>
public readonly record struct AclScopeRoots(
IReadOnlyList<AclScopeNode> Raw,
IReadOnlyList<AclScopeNode> Uns);
/// <summary>
/// The outcome of resolving one authored <c>(ScopeKind, ScopeId)</c> pair against the current
/// configuration — the authoring-time mirror of the deploy gate's <c>NodeAclScope*</c> rules.
/// </summary>
/// <param name="Ok">Whether the scope names something the grant can actually be evaluated against.</param>
/// <param name="Message">
/// What it resolves to when <paramref name="Ok"/>, or what is wrong and how to fix it when not.
/// </param>
public readonly record struct AclScopeCheck(bool Ok, string Message);
/// <summary>How many API keys a principal name currently reaches.</summary>
/// <param name="IsAnonymous">Whether this is the reserved <c>anonymous</c> principal, which has no key.</param>
/// <param name="EnabledKeys">Enabled, unexpired keys authenticating as this principal.</param>
/// <param name="DisabledKeys">Keys naming this principal that are disabled or already expired.</param>
public readonly record struct PrincipalUsage(bool IsAnonymous, int EnabledKeys, int DisabledKeys)
{
/// <summary>
/// Gets a value indicating whether anything can currently present this principal. False means the
/// grant is authored against nobody — which is legitimate while a key is still to be minted, and a
/// typo otherwise, so it is surfaced rather than blocked.
/// </summary>
public bool ReachesSomething => IsAnonymous || EnabledKeys > 0;
}
@@ -0,0 +1,363 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
/// <summary>EF-backed <see cref="IAclScopeService"/>.</summary>
/// <remarks>
/// The two trees are projected from the services that already own them —
/// <see cref="IRawTreeService"/> for the raw side (keeping its lazy expansion) and
/// <see cref="IUnsTreeService"/> for the UNS side (which loads whole) — so the picker cannot show a
/// hierarchy that disagrees with <c>/raw</c> or <c>/uns</c>. Only <see cref="CheckScopeAsync"/> and the
/// principal lookups go to the database directly.
/// </remarks>
public sealed class AclScopeService : IAclScopeService
{
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly IRawTreeService _raw;
private readonly IUnsTreeService _uns;
private readonly TimeProvider _time;
/// <summary>Initializes a new instance of the <see cref="AclScopeService"/> class.</summary>
/// <param name="dbFactory">The config DB context factory.</param>
/// <param name="raw">The raw project-tree service.</param>
/// <param name="uns">The UNS tree service.</param>
/// <param name="time">Clock used to decide whether a key has already expired.</param>
public AclScopeService(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IRawTreeService raw,
IUnsTreeService uns,
TimeProvider? time = null)
{
_dbFactory = dbFactory;
_raw = raw;
_uns = uns;
_time = time ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<AclScopeRoots> LoadRootsAsync(string clusterId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
// Both services root their trees at Enterprise → Cluster, but searching the roots as well as their
// children keeps this working if a cluster ever surfaces at the top level.
var rawRoots = await _raw.LoadRootsAsync(ct).ConfigureAwait(false);
var rawCluster = rawRoots
.Concat(rawRoots.SelectMany(r => r.Children))
.FirstOrDefault(n => n.Kind == RawNodeKind.Cluster
&& string.Equals(n.EntityId, clusterId, StringComparison.Ordinal));
var unsRoots = await _uns.LoadStructureAsync(ct).ConfigureAwait(false);
var unsCluster = unsRoots
.Concat(unsRoots.SelectMany(r => r.Children))
.FirstOrDefault(n => n.Kind == UnsNodeKind.Cluster
&& string.Equals(n.EntityId, clusterId, StringComparison.Ordinal));
var raw = rawCluster is null
? Array.Empty<AclScopeNode>()
: new[] { ProjectRaw(rawCluster, parentPath: null) };
var uns = unsCluster is null
? Array.Empty<AclScopeNode>()
: ProjectUnsChildren(unsCluster, parentPath: null);
return new AclScopeRoots(raw, uns);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AclScopeNode>> LoadChildrenAsync(
AclScopeNode parent, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(parent);
if (parent.RawSource is null) return Array.Empty<AclScopeNode>();
var children = await _raw.LoadChildrenAsync(parent.RawSource, ct).ConfigureAwait(false);
return children.Select(c => ProjectRaw(c, parent.PathLabel)).ToList();
}
/// <inheritdoc />
public async Task<AclScopeCheck> CheckScopeAsync(
string clusterId, NodeAclScopeKind kind, string? scopeId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
// Mirrors DraftValidator.ValidateNodeAclScopes, in its order, so the page and the deploy gate
// cannot disagree about which rows are authorable. The messages are shorter because the operator
// is looking at the field; the rules are the same.
if (!Enum.IsDefined(kind))
return new AclScopeCheck(false,
$"Scope kind ordinal {(int)kind} is not a defined kind — re-pick the scope.");
if (kind == NodeAclScopeKind.Cluster)
return string.IsNullOrWhiteSpace(scopeId)
? new AclScopeCheck(true, $"The whole of cluster '{clusterId}', in both namespaces.")
: new AclScopeCheck(false,
"A Cluster grant covers the whole cluster and ignores the scope target — clear the " +
"target, or pick the level the id names. Leaving both would grant more than intended.");
if (kind == NodeAclScopeKind.FolderSegment)
return new AclScopeCheck(false,
"FolderSegment is a retired v2 kind with no v3 entity behind it; it can never resolve, " +
"whatever its target. Re-scope to RawFolder.");
if (string.IsNullOrWhiteSpace(scopeId))
return new AclScopeCheck(false,
$"{kind} needs a scope target. Only a Cluster grant may omit one; without it the grant " +
"resolves to no node and is dropped when the configuration is built.");
var id = scopeId.Trim();
if (kind == NodeAclScopeKind.Namespace)
{
var realm = NodeAclRealms.TryCanonicalize(id);
return realm is null
? new AclScopeCheck(false,
$"'{id}' names no OPC UA namespace. Use '{NodeAclRealms.Raw}' or '{NodeAclRealms.Uns}'.")
: new AclScopeCheck(true,
realm == NodeAclRealms.Raw
? $"Every raw node in cluster '{clusterId}'."
: $"Every UNS node in cluster '{clusterId}'.");
}
await using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
// Each arm names the entity type the kind's ScopeId is drawn from, and walks to the cluster that
// owns it. Switch arms evaluate lazily, so only the matched kind's query runs. No AsNoTracking:
// every arm projects into Found, and EF tracks nothing it does not materialise as an entity.
var (entity, owner, name) = kind switch
{
NodeAclScopeKind.RawFolder => await ResolveAsync(
"raw folder",
db.RawFolders.Where(f => f.RawFolderId == id).Select(f => new Found(f.ClusterId, f.Name)),
ct).ConfigureAwait(false),
NodeAclScopeKind.RawDriver => await ResolveAsync(
"driver",
db.DriverInstances.Where(d => d.DriverInstanceId == id).Select(d => new Found(d.ClusterId, d.Name)),
ct).ConfigureAwait(false),
NodeAclScopeKind.RawDevice => await ResolveAsync(
"device",
from d in db.Devices
where d.DeviceId == id
join dr in db.DriverInstances on d.DriverInstanceId equals dr.DriverInstanceId
select new Found(dr.ClusterId, d.Name),
ct).ConfigureAwait(false),
NodeAclScopeKind.RawTagGroup => await ResolveAsync(
"tag group",
from g in db.TagGroups
where g.TagGroupId == id
join dev in db.Devices on g.DeviceId equals dev.DeviceId
join dr in db.DriverInstances on dev.DriverInstanceId equals dr.DriverInstanceId
select new Found(dr.ClusterId, g.Name),
ct).ConfigureAwait(false),
NodeAclScopeKind.Tag => await ResolveAsync(
"raw tag",
from t in db.Tags
where t.TagId == id
join dev in db.Devices on t.DeviceId equals dev.DeviceId
join dr in db.DriverInstances on dev.DriverInstanceId equals dr.DriverInstanceId
select new Found(dr.ClusterId, t.Name),
ct).ConfigureAwait(false),
NodeAclScopeKind.UnsArea => await ResolveAsync(
"UNS area",
db.UnsAreas.Where(a => a.UnsAreaId == id).Select(a => new Found(a.ClusterId, a.Name)),
ct).ConfigureAwait(false),
NodeAclScopeKind.UnsLine => await ResolveAsync(
"UNS line",
from l in db.UnsLines
where l.UnsLineId == id
join a in db.UnsAreas on l.UnsAreaId equals a.UnsAreaId
select new Found(a.ClusterId, l.Name),
ct).ConfigureAwait(false),
NodeAclScopeKind.Equipment => await ResolveAsync(
"equipment",
from e in db.Equipment
where e.EquipmentId == id
join l in db.UnsLines on e.UnsLineId equals l.UnsLineId
join a in db.UnsAreas on l.UnsAreaId equals a.UnsAreaId
select new Found(a.ClusterId, e.Name),
ct).ConfigureAwait(false),
// Cluster / Namespace / FolderSegment all returned above, and an undefined ordinal did too.
_ => ("scope", null, null),
};
if (owner is null)
return new AclScopeCheck(false,
$"No {entity} in this configuration has the id '{id}' — or its parent chain is broken, so " +
"no cluster owns it. The grant would be dropped when the configuration is built and would " +
"authorize nothing.");
if (!string.Equals(owner, clusterId, StringComparison.OrdinalIgnoreCase))
return new AclScopeCheck(false,
$"That {entity} belongs to cluster '{owner}', but this grant is in cluster '{clusterId}'. " +
"The id is real, so the grant would confer authority across a cluster boundary. Author it " +
$"under '{owner}' instead.");
return new AclScopeCheck(true,
$"{char.ToUpperInvariant(entity[0])}{entity[1..]} '{name}' in cluster '{clusterId}'.");
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> LoadKnownPrincipalsAsync(CancellationToken ct = default)
{
await using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var fromKeys = await db.ApiKeys.AsNoTracking().Select(k => k.Principal).Distinct()
.ToListAsync(ct).ConfigureAwait(false);
var fromAcls = await db.NodeAcls.AsNoTracking().Select(a => a.Principal).Distinct()
.ToListAsync(ct).ConfigureAwait(false);
return fromKeys
.Concat(fromAcls)
.Append(OpcUaPrincipals.Anonymous)
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <inheritdoc />
public async Task<PrincipalUsage> CheckPrincipalAsync(string principal, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(principal)) return new PrincipalUsage(false, 0, 0);
var name = principal.Trim();
if (string.Equals(name, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase))
return new PrincipalUsage(true, 0, 0);
await using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var keys = await db.ApiKeys.AsNoTracking()
.Where(k => k.Principal == name)
.Select(k => new { k.Enabled, k.ExpiresUtc })
.ToListAsync(ct).ConfigureAwait(false);
var now = _time.GetUtcNow().UtcDateTime;
var live = keys.Count(k => k.Enabled && (k.ExpiresUtc is null || k.ExpiresUtc > now));
return new PrincipalUsage(false, live, keys.Count - live);
}
// --- Projection --------------------------------------------------------------------------------
private static AclScopeNode ProjectRaw(RawNode node, string? parentPath)
{
var kind = RawScopeKind(node.Kind);
var path = Join(parentPath, node.DisplayName);
var projected = new AclScopeNode
{
Key = node.Key,
DisplayName = node.DisplayName,
Icon = RawIcon(node.Kind),
ScopeKind = kind,
// A Cluster grant covers its whole cluster and carries no id — the deploy gate rejects one
// that does, so the picker must not produce one.
ScopeId = kind == NodeAclScopeKind.Cluster ? null : node.EntityId,
PathLabel = path,
HasLazyChildren = node.HasLazyChildren,
RawSource = node,
};
foreach (var child in node.Children) projected.Children.Add(ProjectRaw(child, path));
return projected;
}
private static IReadOnlyList<AclScopeNode> ProjectUnsChildren(UnsNode cluster, string? parentPath) =>
cluster.Children.Select(c => ProjectUns(c, parentPath)).ToList();
private static AclScopeNode ProjectUns(UnsNode node, string? parentPath)
{
var path = Join(parentPath, node.DisplayName);
var projected = new AclScopeNode
{
Key = node.Key,
DisplayName = node.DisplayName,
Icon = UnsIcon(node.Kind),
ScopeKind = UnsScopeKind(node.Kind),
ScopeId = node.EntityId,
PathLabel = path,
HasLazyChildren = false,
// Everything below the cluster is already materialised — nothing to expand lazily, and no raw
// node to expand it from.
RawSource = null,
Expanded = true,
};
foreach (var child in node.Children) projected.Children.Add(ProjectUns(child, path));
return projected;
}
private static string Join(string? parent, string name) =>
string.IsNullOrEmpty(parent) ? name : $"{parent} / {name}";
private static NodeAclScopeKind? RawScopeKind(RawNodeKind kind) => kind switch
{
RawNodeKind.Cluster => NodeAclScopeKind.Cluster,
RawNodeKind.Folder => NodeAclScopeKind.RawFolder,
RawNodeKind.Driver => NodeAclScopeKind.RawDriver,
RawNodeKind.Device => NodeAclScopeKind.RawDevice,
RawNodeKind.TagGroup => NodeAclScopeKind.RawTagGroup,
RawNodeKind.Tag => NodeAclScopeKind.Tag,
_ => null, // Enterprise: a label, not a scope.
};
// Area/Line/Equipment only. A UNS tag row is a UnsTagReference, not a Tag — its scope is the RAW tag it
// points at, and a Tag grant already covers the raw node AND every UNS projection of it (see
// AclScopePathResolver). Offering a UNS tag here would produce a grant that reads as narrower than the
// one it actually creates.
private static NodeAclScopeKind? UnsScopeKind(UnsNodeKind kind) => kind switch
{
UnsNodeKind.Area => NodeAclScopeKind.UnsArea,
UnsNodeKind.Line => NodeAclScopeKind.UnsLine,
UnsNodeKind.Equipment => NodeAclScopeKind.Equipment,
_ => null,
};
private static string RawIcon(RawNodeKind kind) => kind switch
{
RawNodeKind.Enterprise => "🏢",
RawNodeKind.Cluster => "🖥️",
RawNodeKind.Folder => "📁",
RawNodeKind.Driver => "🔌",
RawNodeKind.Device => "📟",
RawNodeKind.TagGroup => "🗂️",
RawNodeKind.Tag => "🏷️",
_ => "•",
};
private static string UnsIcon(UnsNodeKind kind) => kind switch
{
UnsNodeKind.Area => "🗺️",
UnsNodeKind.Line => "🏭",
UnsNodeKind.Equipment => "⚙️",
_ => "•",
};
// --- Resolution helpers ------------------------------------------------------------------------
// A class, not a struct: FirstOrDefaultAsync over a struct yields default(T), which is indistinguishable
// from a real row whose fields happen to be empty — a null reference cannot be misread that way.
private sealed record Found(string ClusterId, string Name);
private static async Task<(string Entity, string? Owner, string? Name)> ResolveAsync(
string entity, IQueryable<Found> query, CancellationToken ct)
{
// FirstOrDefault, not Single: a duplicated id is a different rule's business, and throwing here
// would replace a precise message with an exception page.
var hit = await query.FirstOrDefaultAsync(ct).ConfigureAwait(false);
return hit is null ? (entity, null, null) : (entity, hit.ClusterId, hit.Name);
}
}
@@ -0,0 +1,72 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
/// <summary>
/// Backs the ACL authoring surface (#520 stage 7): the scope picker's two trees, and the
/// authoring-time checks that stop a grant being saved against a principal or a scope that names
/// nothing.
/// </summary>
/// <remarks>
/// <para>
/// <b>A service rather than logic in the Razor pages, because this repo has no bUnit</b> — anything
/// only a page can reach is untestable, and the whole point of the stage is that authoring stops
/// producing grants that silently authorize nobody. The same rationale
/// <c>IApiKeyAdminService</c> carries.
/// </para>
/// <para>
/// <b>The picker chooses the kind and the id together.</b> Before this, <c>ScopeKind</c> was a
/// dropdown and <c>ScopeId</c> a free-text box, so the two could disagree — a
/// <see cref="NodeAclScopeKind.RawDevice"/> row pointing at a TagId is well-formed, saves, deploys,
/// and resolves to nothing. Selecting a tree node produces both, so that pairing cannot be made by
/// hand. <see cref="CheckScopeAsync"/> is what catches the rows that already exist, or that were
/// typed in before the picker was reached.
/// </para>
/// </remarks>
public interface IAclScopeService
{
/// <summary>
/// Loads the raw and UNS scope trees for one cluster. Both are rooted at that cluster, so a scope in
/// another cluster is unreachable through the picker rather than merely discouraged — the structural
/// equivalent of the deploy gate's <c>NodeAclScopeCrossCluster</c> rule.
/// </summary>
/// <param name="clusterId">The cluster the grant belongs to.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The two trees; either may be empty when the cluster has nothing of that kind yet.</returns>
Task<AclScopeRoots> LoadRootsAsync(string clusterId, CancellationToken ct = default);
/// <summary>
/// Loads one lazy level beneath a raw-tree node. UNS nodes are loaded whole by
/// <see cref="LoadRootsAsync"/> and never reach here.
/// </summary>
/// <param name="parent">The node being expanded.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The child rows, or empty for a leaf.</returns>
Task<IReadOnlyList<AclScopeNode>> LoadChildrenAsync(AclScopeNode parent, CancellationToken ct = default);
/// <summary>
/// Resolves an authored scope against the current configuration, mirroring the deploy gate's
/// <c>NodeAclScope*</c> rules so the page rejects what the deployment would reject.
/// </summary>
/// <param name="clusterId">The cluster the grant belongs to.</param>
/// <param name="kind">The authored scope kind.</param>
/// <param name="scopeId">The authored scope id; null or blank for a cluster-wide grant.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>What the scope resolves to, or why it does not.</returns>
Task<AclScopeCheck> CheckScopeAsync(
string clusterId, NodeAclScopeKind kind, string? scopeId, CancellationToken ct = default);
/// <summary>
/// Lists the principal names worth suggesting: every principal an API key authenticates as, every
/// principal an existing grant already names, and the reserved <c>anonymous</c> principal.
/// </summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>Distinct principal names, ordered.</returns>
Task<IReadOnlyList<string>> LoadKnownPrincipalsAsync(CancellationToken ct = default);
/// <summary>Reports how many API keys currently authenticate as a principal.</summary>
/// <param name="principal">The principal name as authored.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The usage counts.</returns>
Task<PrincipalUsage> CheckPrincipalAsync(string principal, CancellationToken ct = default);
}
@@ -5,10 +5,13 @@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject IAclScopeService Scopes
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -28,47 +31,35 @@ else if (!IsNew && _existing is null)
}
else
{
<div class="alert alert-warning" role="alert">
<div class="alert alert-info" role="alert">
<strong>What this grant does depends on <span class="mono">Security:Acl:Enabled</span> on the
node serving the data.</strong> That is per-node configuration, so this page cannot tell you
which mode a given node is in — check the node's own settings.
<br />
<strong>With it off (the default):</strong> saved with scope kind
<span class="mono">Cluster</span> this grant confers a <em>server-wide</em> role on its
principal — <span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
(acknowledge / confirm / shelve). Saved with any narrower scope
(<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
<span class="mono">Tag</span>, <span class="mono">Raw*</span>) it is stored and shipped in every
deployment artifact but <strong>confers nothing</strong>. That under-granting is deliberate:
honouring its flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on one
equipment" into server-wide write authority.
<br />
<strong>With it on:</strong> the scope is honoured, and the scoped decision <em>replaces</em> the
server-wide role rather than adding to it. <strong>Every data-plane operation is gated</strong> —
<span class="mono">Read</span>, <span class="mono">Browse</span>,
<span class="mono">Subscribe</span>, <span class="mono">WriteOperate</span>,
<span class="mono">HistoryRead</span> and the alarm methods, which separate into
<span class="mono">AlarmAcknowledge</span> / <span class="mono">AlarmConfirm</span> /
<span class="mono">AlarmShelve</span>. So a grant that omits
<span class="mono">Read</span> denies reads, and one that omits
<span class="mono">Browse</span> hides the node from navigation.
<br />
<span class="mono">AlarmShelve</span>. So a grant that omits <span class="mono">Read</span>
denies reads, and one that omits <span class="mono">Browse</span> hides the node from navigation.
Granting <span class="mono">Browse</span> on a node also makes the folders <em>above</em> it
browsable — otherwise the grant could never be navigated to — so it reveals their names.
Revoking this grant stops any live subscription it was authorizing, on the next deployment.
<br />
<strong>Principal</strong> is an API-key principal — or the reserved
<span class="mono">anonymous</span> principal — not an LDAP group: a client presents an OPC UA
UserName token with username <span class="mono">apikey</span> and password
<span class="mono">otua_&lt;keyId&gt;_&lt;secret&gt;</span>. Mint keys on the
<span class="mono">/apikeys</span> page; LDAP now covers AdminUI login only.
See <span class="mono">docs/security.md</span>.
<strong>With it off (the default):</strong> only a <span class="mono">Cluster</span>-scoped grant
does anything, and it confers a <em>server-wide</em> role on its principal —
<span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods. Any narrower scope is
stored and shipped but <strong>confers nothing</strong>. That under-granting is deliberate:
honouring a grant's flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on
one equipment" into server-wide write authority.
</div>
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
<DataAnnotationsValidator />
<ValidationSummary class="alert alert-danger" />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Grant</div>
<div style="padding:1rem">
@@ -79,49 +70,71 @@ else
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="grp">Principal</label>
<InputText id="grp" @bind-Value="_form.Principal" class="form-control form-control-sm mono"
placeholder="scadabridge-dcl" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="scope">Scope kind</label>
<InputSelect id="scope" @bind-Value="_form.ScopeKind" class="form-select form-select-sm">
<option value="@NodeAclScopeKind.Cluster">Cluster</option>
<option value="@NodeAclScopeKind.Namespace">Namespace</option>
<option value="@NodeAclScopeKind.UnsArea">UnsArea</option>
<option value="@NodeAclScopeKind.UnsLine">UnsLine</option>
<option value="@NodeAclScopeKind.Equipment">Equipment</option>
<option value="@NodeAclScopeKind.FolderSegment">FolderSegment (retired — do not author)</option>
<option value="@NodeAclScopeKind.Tag">Tag</option>
<option value="@NodeAclScopeKind.RawFolder">RawFolder</option>
<option value="@NodeAclScopeKind.RawDriver">RawDriver</option>
<option value="@NodeAclScopeKind.RawDevice">RawDevice</option>
<option value="@NodeAclScopeKind.RawTagGroup">RawTagGroup</option>
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="target">Scope target ID</label>
<InputText id="target" @bind-Value="_form.ScopeId" class="form-control form-control-sm mono"
placeholder="Leave blank for cluster-wide" />
<input id="grp" class="form-control form-control-sm mono" list="acl-principals"
placeholder="scadabridge-dcl"
@bind="_form.Principal" @bind:after="RefreshPrincipalAsync" />
<datalist id="acl-principals">
@foreach (var p in _knownPrincipals)
{
<option value="@p"></option>
}
</datalist>
<div class="form-text">
Free text, no picker — a typo saves a grant that resolves to nothing.
<span class="mono">Cluster</span> → leave blank ·
<span class="mono">Namespace</span> → <span class="mono">raw</span> or
<span class="mono">uns</span> ·
<span class="mono">UnsArea</span> → a UnsAreaId ·
<span class="mono">UnsLine</span> → a UnsLineId ·
<span class="mono">Equipment</span> → an EquipmentId ·
<span class="mono">Tag</span> → a TagId (a raw tag; the grant covers the raw
node and every UNS projection of it) ·
<span class="mono">RawFolder</span> → a RawFolderId ·
<span class="mono">RawDriver</span> → a DriverInstanceId ·
<span class="mono">RawDevice</span> → a DeviceId ·
<span class="mono">RawTagGroup</span> → a TagGroupId ·
<span class="mono">FolderSegment</span> → retired v2 kind, resolves nothing.
@if (string.IsNullOrWhiteSpace(_form.Principal))
{
<span>
An API key's principal, or the reserved
<span class="mono">@OpcUaPrincipals.Anonymous</span> principal that an
anonymous session authenticates as. Mint keys on
<a href="/apikeys">/apikeys</a>.
</span>
}
else if (_principal.IsAnonymous)
{
<span class="text-success">
✓ The reserved anonymous principal — every session admitted without a
key authenticates as this.
</span>
}
else if (_principal.EnabledKeys > 0)
{
<span class="text-success">
✓ @_principal.EnabledKeys active API key@(_principal.EnabledKeys == 1 ? "" : "s")
@(_principal.DisabledKeys > 0 ? $" (+{_principal.DisabledKeys} revoked or expired)" : "")
authenticate@(_principal.EnabledKeys == 1 ? "s" : "") as this principal.
</span>
}
else
{
<span class="text-warning">
⚠ No active API key uses this principal@(_principal.DisabledKeys > 0 ? " (only revoked or expired ones)" : "") —
the grant is valid but currently authorizes nobody. That is fine if the
key is still to be minted; otherwise check the spelling.
</span>
}
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Scope</label>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="chip chip-idle">@_form.ScopeKind</span>
<span class="mono small">@(string.IsNullOrEmpty(_form.ScopeId) ? "(whole cluster)" : _form.ScopeId)</span>
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="OpenPicker">
Choose scope…
</button>
</div>
<div class="form-text @(_scope.Ok ? "text-success" : "text-danger")">
@(_scope.Ok ? "✓ " : "✗ ")@_scope.Message
</div>
<div class="form-text">
Permissions inherit downward: a grant on a device covers every tag beneath it.
Picking the scope from the tree is what keeps the kind and the target in step —
a kind that disagrees with its target resolves to nothing and authorizes nobody.
</div>
</div>
<div class="mb-3">
<label class="form-label">Permissions</label>
<div>
@@ -162,6 +175,9 @@ else
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
</div>
</EditForm>
<AclScopePickerModal Visible="_pickerVisible" ClusterId="@ClusterId"
OnPicked="ScopePicked" OnCancel="() => _pickerVisible = false" />
}
@code {
@@ -183,6 +199,11 @@ else
private bool _loaded;
private bool _busy;
private string? _error;
private bool _pickerVisible;
private IReadOnlyList<string> _knownPrincipals = Array.Empty<string>();
private PrincipalUsage _principal;
private AclScopeCheck _scope;
protected override async Task OnInitializedAsync()
{
@@ -205,14 +226,46 @@ else
};
}
}
_knownPrincipals = await Scopes.LoadKnownPrincipalsAsync();
await RefreshPrincipalAsync();
await RefreshScopeAsync();
_loaded = true;
}
private void OpenPicker()
{
_pickerVisible = true;
StateHasChanged();
}
private async Task ScopePicked(AclScopeNode node)
{
// The picker supplies both halves at once — the pairing is what stops a kind naming one entity
// type while its id names another.
_form.ScopeKind = node.ScopeKind!.Value;
_form.ScopeId = node.ScopeId;
_pickerVisible = false;
await RefreshScopeAsync();
}
private async Task RefreshPrincipalAsync() =>
_principal = await Scopes.CheckPrincipalAsync(_form.Principal ?? "");
private async Task RefreshScopeAsync() =>
_scope = await Scopes.CheckScopeAsync(ClusterId, _form.ScopeKind, _form.ScopeId);
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
// Re-check rather than trust the cached verdict: an existing row may have been loaded with a
// scope whose entity has since been deleted, and the operator may never have touched the
// picker. Blocking here is what stops the page saving a grant the deployment would reject.
await RefreshScopeAsync();
if (!_scope.Ok) { _error = $"Scope: {_scope.Message}"; return; }
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -2,9 +2,11 @@
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject IAclScopeService Scopes
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">ACLs &middot; <span class="mono">@ClusterId</span></h4>
@@ -19,24 +21,12 @@
}
else
{
<div class="alert alert-warning" role="alert">
<div class="alert alert-info" role="alert">
<strong>What these grants do depends on
<span class="mono">Security:Acl:Enabled</span> on the node serving the data.</strong>
That is per-node configuration, so this page cannot tell you which mode a given node is in —
check the node's own settings.
<br />
<strong>With it off (the default):</strong> only <span class="mono">Cluster</span>-scoped grants
do anything, and they confer a <em>server-wide</em> role on their principal —
<span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
(acknowledge / confirm / shelve). Narrower grants
(<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
<span class="mono">Tag</span>, <span class="mono">Raw*</span>) are saved and shipped in every
deployment artifact but confer <strong>nothing</strong>. That under-granting is deliberate:
honouring such a grant's flags while its <em>scope</em> cannot be honoured would turn
"WriteOperate on one equipment" into server-wide write authority.
<br />
<strong>With it on:</strong> every scope kind is honoured, and the scoped decision
<em>replaces</em> the server-wide role rather than adding to it — so a narrow grant works, and a
Cluster-scoped one no longer confers authority beyond that cluster.
@@ -47,6 +37,15 @@ else
<span class="mono">AlarmConfirm</span> / <span class="mono">AlarmShelve</span>. A principal with
no grant at all can connect and see nothing.
<br />
<strong>With it off (the default):</strong> only <span class="mono">Cluster</span>-scoped grants
do anything, and they confer a <em>server-wide</em> role on their principal —
<span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
(acknowledge / confirm / shelve). Narrower grants are saved and shipped in every deployment
artifact but confer <strong>nothing</strong>. That under-granting is deliberate: honouring such
a grant's flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on one
equipment" into server-wide write authority.
<br />
A node that merely <em>leads to</em> something you may browse stays browsable, or a narrow grant
could never be navigated to — so granting <span class="mono">Browse</span> deep in the tree also
reveals the names of the folders above it. Revoking a grant stops live subscriptions on the next
@@ -64,13 +63,23 @@ else
(the cluster, a namespace, an area, an equipment, a raw device, a tag). A principal is an API
key's principal, or the reserved <span class="mono">anonymous</span> principal that an
anonymous session authenticates as.
<br />
The <strong>Scope target</strong> column resolves each row against the current configuration. A
row marked <span class="chip chip-alert">unresolved</span> points at nothing — it is shipped in
every deployment artifact but authorizes nobody, and the deploy gate rejects it. Open it and
re-pick the scope.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count ACL row@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No ACL rows for this cluster — no principal holds <span class="mono">WriteOperate</span> or <span class="mono">AlarmAck</span> here, so writes and alarm acknowledges are refused. Reads, browses, subscriptions and history reads are unaffected.</div>
<div style="padding:1rem" class="text-muted">
No ACL rows for this cluster. With <span class="mono">Security:Acl:Enabled</span> off, that
means writes and alarm acknowledges are refused while reads, browses, subscriptions and
history reads stay open to any admitted session. With it <strong>on</strong>, it means
every data-plane operation is refused here — a client can connect and see nothing.
</div>
}
else
{
@@ -92,9 +101,29 @@ else
{
<tr>
<td><span class="mono small">@a.NodeAclId</span></td>
<td><span class="mono">@a.Principal</span></td>
<td>
<span class="mono">@a.Principal</span>
@if (_principals.TryGetValue(a.Principal, out var use) && !use.ReachesSomething)
{
<span class="chip chip-warn ms-1" title="No active API key authenticates as this principal, so the grant currently authorizes nobody.">no key</span>
}
</td>
<td>@a.ScopeKind</td>
<td><span class="mono small">@(a.ScopeId ?? "—")</span></td>
<td>
<span class="mono small">@(a.ScopeId ?? "—")</span>
@if (_scopes.TryGetValue(a.NodeAclId, out var check))
{
@if (check.Ok)
{
<div class="text-muted small">@check.Message</div>
}
else
{
<div><span class="chip chip-alert" title="@check.Message">unresolved</span></div>
<div class="text-danger small">@check.Message</div>
}
}
</td>
<td>
@foreach (var perm in PermissionChips(a.PermissionFlags))
{
@@ -115,6 +144,8 @@ else
@code {
[Parameter] public string ClusterId { get; set; } = "";
private List<NodeAcl>? _rows;
private Dictionary<string, AclScopeCheck> _scopes = new(StringComparer.Ordinal);
private Dictionary<string, PrincipalUsage> _principals = new(StringComparer.OrdinalIgnoreCase);
protected override async Task OnInitializedAsync()
{
@@ -123,6 +154,19 @@ else
.Where(a => a.ClusterId == ClusterId)
.OrderBy(a => a.NodeAclId)
.ToListAsync();
// Resolve every row's scope and principal so an unresolved grant is visible from the list rather
// than only at deploy time. One indexed lookup each; the distinct-principal grouping keeps the
// key lookups down to the number of distinct principals, not the number of rows.
foreach (var row in _rows)
{
_scopes[row.NodeAclId] = await Scopes.CheckScopeAsync(ClusterId, row.ScopeKind, row.ScopeId);
}
foreach (var principal in _rows.Select(r => r.Principal).Distinct(StringComparer.OrdinalIgnoreCase))
{
_principals[principal] = await Scopes.CheckPrincipalAsync(principal);
}
}
private static IEnumerable<string> PermissionChips(ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodePermissions flags)
@@ -0,0 +1,190 @@
@* #520 stage 7 — pick a NodeAcl grant's scope from the real configuration.
The point of this modal is that it produces the ScopeKind and the ScopeId TOGETHER, from a node that
exists. Before it, the two were an unrelated dropdown and free-text box, so "RawDevice" + a TagId was a
well-formed row that saved, deployed, and resolved to nothing.
The trees are rooted at the grant's own cluster, so a cross-cluster scope is structurally unreachable
here — the picker's counterpart to the deploy gate's NodeAclScopeCrossCluster rule. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
@inject IAclScopeService Scopes
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Choose scope &middot; <span class="mono">@ClusterId</span></h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3">
Pick the node this grant applies to. Permissions inherit <strong>downward</strong>,
so a grant on a device covers every tag beneath it. Both trees are scoped to this
cluster, so a grant cannot be pointed at another cluster's node.
</p>
@if (_loading)
{
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
}
else
{
<div class="row g-3">
<div class="col-md-6">
<div class="panel-head mb-1">Raw device tree</div>
<div class="border rounded p-2" style="max-height:50vh; overflow:auto">
<AclScopeTree Nodes="_rawHeader" SelectedKey="@_selected?.Key" OnSelect="Select" />
@if (_roots.Raw.Count == 0)
{
<p class="text-muted small mb-0 mt-1">This cluster has no raw tree yet.</p>
}
else
{
<AclScopeTree Nodes="_roots.Raw" SelectedKey="@_selected?.Key"
OnSelect="Select" LoadChildren="LoadChildrenAsync" />
}
</div>
</div>
<div class="col-md-6">
<div class="panel-head mb-1">UNS tree</div>
<div class="border rounded p-2" style="max-height:50vh; overflow:auto">
<AclScopeTree Nodes="_unsHeader" SelectedKey="@_selected?.Key" OnSelect="Select" />
@if (_roots.Uns.Count == 0)
{
<p class="text-muted small mb-0 mt-1">This cluster has no UNS areas yet.</p>
}
else
{
<AclScopeTree Nodes="_roots.Uns" SelectedKey="@_selected?.Key" OnSelect="Select" />
}
</div>
<p class="text-muted small mt-2 mb-0">
UNS tags are not listed: a UNS signal is a <em>reference</em> to a raw
tag, and a <span class="mono">Tag</span> grant already covers the raw
node <em>and</em> every UNS projection of it. Pick the tag in the raw
tree.
</p>
</div>
</div>
}
</div>
<div class="modal-footer d-flex justify-content-between">
<div class="small">
@if (_selected is null)
{
<span class="text-muted">Nothing selected.</span>
}
else
{
<span>
<span class="chip chip-idle me-1">@_selected.ScopeKind</span>
<span class="mono">@_selected.PathLabel</span>
@if (_selected.ScopeId is not null)
{
<span class="text-muted ms-1">(@_selected.ScopeId)</span>
}
</span>
}
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="ConfirmAsync"
disabled="@(_selected is null)">Use this scope</button>
</div>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>The cluster the grant belongs to — both trees are rooted here.</summary>
[Parameter] public string ClusterId { get; set; } = "";
/// <summary>Raised with the chosen scope. The host reads ScopeKind + ScopeId off it.</summary>
[Parameter] public EventCallback<AclScopeNode> OnPicked { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private AclScopeRoots _roots = new(Array.Empty<AclScopeNode>(), Array.Empty<AclScopeNode>());
private AclScopeNode? _selected;
private bool _loading;
private bool _wasVisible;
// Whole-namespace rows. They are real scopes (NodeAclScopeKind.Namespace) with no entity behind them,
// so they are synthesized here rather than coming from a tree.
private readonly IReadOnlyList<AclScopeNode> _rawHeader = new[]
{
new AclScopeNode
{
Key = "ns:raw",
DisplayName = "Every raw node",
Icon = "🌐",
ScopeKind = NodeAclScopeKind.Namespace,
ScopeId = NodeAclRealms.Raw,
PathLabel = "raw namespace",
},
};
private readonly IReadOnlyList<AclScopeNode> _unsHeader = new[]
{
new AclScopeNode
{
Key = "ns:uns",
DisplayName = "Every UNS node",
Icon = "🌐",
ScopeKind = NodeAclScopeKind.Namespace,
ScopeId = NodeAclRealms.Uns,
PathLabel = "uns namespace",
},
};
protected override async Task OnParametersSetAsync()
{
// Reload only on the false→true transition, so a re-render while open does not wipe the
// expansion state the operator has built up.
if (Visible && !_wasVisible)
{
_selected = null;
_loading = true;
_roots = new AclScopeRoots(Array.Empty<AclScopeNode>(), Array.Empty<AclScopeNode>());
StateHasChanged();
_roots = string.IsNullOrWhiteSpace(ClusterId)
? new AclScopeRoots(Array.Empty<AclScopeNode>(), Array.Empty<AclScopeNode>())
: await Scopes.LoadRootsAsync(ClusterId);
// The cluster root is the one node worth opening for the operator — everything else is a
// decision they should make deliberately.
foreach (var root in _roots.Raw) { root.Expanded = false; }
_loading = false;
}
_wasVisible = Visible;
}
private Task<IReadOnlyList<AclScopeNode>> LoadChildrenAsync(AclScopeNode node) =>
Scopes.LoadChildrenAsync(node);
private void Select(AclScopeNode node)
{
_selected = node;
StateHasChanged();
}
private async Task ConfirmAsync()
{
if (_selected is null) { return; }
await OnPicked.InvokeAsync(_selected);
}
private Task CancelAsync() => OnCancel.InvokeAsync();
}
@@ -0,0 +1,118 @@
@* #520 stage 7 — recursive, single-select renderer for the ACL scope picker.
Deliberately a separate component from RawTree rather than a third mode on it. RawTree already carries
the /raw editing tree AND the equipment reference picker; a third mode would put the scope picker's
bugs on the same page as device authoring, and this repo has no bUnit to catch that. This renders the
unified AclScopeNode, so ONE component covers both the raw and the UNS hierarchy. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
@foreach (var node in Nodes)
{
@RenderNode(node, 0)
}
@code {
/// <summary>The rows to render at this level.</summary>
[Parameter, EditorRequired] public IReadOnlyList<AclScopeNode> Nodes { get; set; } = default!;
/// <summary>The <see cref="AclScopeNode.Key"/> of the currently-selected row, if any.</summary>
[Parameter] public string? SelectedKey { get; set; }
/// <summary>Raised when a selectable row is chosen. Single-select: a grant has exactly one scope.</summary>
[Parameter] public EventCallback<AclScopeNode> OnSelect { get; set; }
/// <summary>
/// Loads one lazy level (the raw side). Wired by the host to <c>IAclScopeService.LoadChildrenAsync</c>;
/// when absent, every node is treated as already materialised.
/// </summary>
[Parameter] public Func<AclScopeNode, Task<IReadOnlyList<AclScopeNode>>>? LoadChildren { get; set; }
private RenderFragment RenderNode(AclScopeNode node, int depth) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var hasExpander = node.HasLazyChildren || node.Children.Count > 0;
var selected = node.Selectable && node.Key == SelectedKey;
<div @key="node.Key" class="d-flex align-items-center gap-1 py-1" style="@indent">
@if (hasExpander)
{
<button type="button" class="btn btn-sm btn-link p-0" style="width:18px"
@onclick="@(() => ToggleAsync(node))">
@(node.Expanded ? "▼" : "▶")
</button>
}
else
{
<span style="width:18px"></span>
}
@if (node.Selectable)
{
<button type="button"
class="btn btn-sm p-0 px-1 border-0 d-inline-flex align-items-center gap-1 @(selected ? "bg-primary text-white rounded" : "btn-link text-decoration-none")"
@onclick="@(() => OnSelect.InvokeAsync(node))">
<span aria-hidden="true">@node.Icon</span>
<span class="mono small">@node.DisplayName</span>
<span class="chip chip-idle ms-1" style="font-size:0.65rem">@node.ScopeKind</span>
</button>
}
else
{
@* Label only — an enterprise heading, or a level with no ACL scope kind behind it. *@
<span class="d-inline-flex align-items-center gap-1 text-muted">
<span aria-hidden="true">@node.Icon</span>
<span class="mono small">@node.DisplayName</span>
</span>
}
</div>
@if (node.Expanded && node.Loading)
{
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
}
else if (node.Expanded && node.Error is not null)
{
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">@node.Error</div>
}
else if (node.Expanded)
{
@foreach (var child in node.Children)
{
@RenderNode(child, depth + 1)
}
}
};
private async Task ToggleAsync(AclScopeNode node)
{
if (node.Loading) { return; }
node.Expanded = !node.Expanded;
if (node.Expanded && node.HasLazyChildren && !node.Loaded && LoadChildren is not null)
{
node.Loading = true;
node.Error = null;
StateHasChanged();
try
{
var children = await LoadChildren(node);
node.Children.Clear();
node.Children.AddRange(children);
node.Loaded = true;
}
catch (Exception ex)
{
node.Error = ex.Message;
}
finally
{
node.Loading = false;
}
}
StateHasChanged();
}
}
@@ -72,6 +72,10 @@ public static class EndpointRouteBuilderExtensions
// UnsTreeService's reference/VirtualTag/ScriptedAlarm mutations (WP1); mirrors the
// deploy-time DraftValidator UnsEffectiveNameCollision rule.
services.AddScoped<IEffectiveNameGuard, EffectiveNameGuard>();
// #520 stage 7: the ACL scope picker's two trees + the authoring-time scope/principal checks that
// mirror the deploy gate's NodeAclScope* rules. Scoped, because it composes the two scoped tree
// services above.
services.AddScoped<Acls.IAclScopeService, Acls.AclScopeService>();
// WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
services.AddScoped<RawTagCsvExportReader>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
@@ -7,6 +7,7 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.JSInterop
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Acls
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout
@using ZB.MOM.WW.OtOpcUa.Security.Auth
@using ZB.MOM.WW.Theme