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:
+190
@@ -0,0 +1,190 @@
|
||||
@* #520 stage 7 — pick a NodeAcl grant's scope from the real configuration.
|
||||
|
||||
The point of this modal is that it produces the ScopeKind and the ScopeId TOGETHER, from a node that
|
||||
exists. Before it, the two were an unrelated dropdown and free-text box, so "RawDevice" + a TagId was a
|
||||
well-formed row that saved, deployed, and resolved to nothing.
|
||||
|
||||
The trees are rooted at the grant's own cluster, so a cross-cluster scope is structurally unreachable
|
||||
here — the picker's counterpart to the deploy gate's NodeAclScopeCrossCluster rule. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
|
||||
@inject IAclScopeService Scopes
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Choose scope · <span class="mono">@ClusterId</span></h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-3">
|
||||
Pick the node this grant applies to. Permissions inherit <strong>downward</strong>,
|
||||
so a grant on a device covers every tag beneath it. Both trees are scoped to this
|
||||
cluster, so a grant cannot be pointed at another cluster's node.
|
||||
</p>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="panel-head mb-1">Raw device tree</div>
|
||||
<div class="border rounded p-2" style="max-height:50vh; overflow:auto">
|
||||
<AclScopeTree Nodes="_rawHeader" SelectedKey="@_selected?.Key" OnSelect="Select" />
|
||||
@if (_roots.Raw.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0 mt-1">This cluster has no raw tree yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<AclScopeTree Nodes="_roots.Raw" SelectedKey="@_selected?.Key"
|
||||
OnSelect="Select" LoadChildren="LoadChildrenAsync" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="panel-head mb-1">UNS tree</div>
|
||||
<div class="border rounded p-2" style="max-height:50vh; overflow:auto">
|
||||
<AclScopeTree Nodes="_unsHeader" SelectedKey="@_selected?.Key" OnSelect="Select" />
|
||||
@if (_roots.Uns.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0 mt-1">This cluster has no UNS areas yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<AclScopeTree Nodes="_roots.Uns" SelectedKey="@_selected?.Key" OnSelect="Select" />
|
||||
}
|
||||
</div>
|
||||
<p class="text-muted small mt-2 mb-0">
|
||||
UNS tags are not listed: a UNS signal is a <em>reference</em> to a raw
|
||||
tag, and a <span class="mono">Tag</span> grant already covers the raw
|
||||
node <em>and</em> every UNS projection of it. Pick the tag in the raw
|
||||
tree.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer d-flex justify-content-between">
|
||||
<div class="small">
|
||||
@if (_selected is null)
|
||||
{
|
||||
<span class="text-muted">Nothing selected.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>
|
||||
<span class="chip chip-idle me-1">@_selected.ScopeKind</span>
|
||||
<span class="mono">@_selected.PathLabel</span>
|
||||
@if (_selected.ScopeId is not null)
|
||||
{
|
||||
<span class="text-muted ms-1">(@_selected.ScopeId)</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="ConfirmAsync"
|
||||
disabled="@(_selected is null)">Use this scope</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>The cluster the grant belongs to — both trees are rooted here.</summary>
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
|
||||
/// <summary>Raised with the chosen scope. The host reads ScopeKind + ScopeId off it.</summary>
|
||||
[Parameter] public EventCallback<AclScopeNode> OnPicked { get; set; }
|
||||
|
||||
/// <summary>Raised when the user cancels so the host can close.</summary>
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private AclScopeRoots _roots = new(Array.Empty<AclScopeNode>(), Array.Empty<AclScopeNode>());
|
||||
private AclScopeNode? _selected;
|
||||
private bool _loading;
|
||||
private bool _wasVisible;
|
||||
|
||||
// Whole-namespace rows. They are real scopes (NodeAclScopeKind.Namespace) with no entity behind them,
|
||||
// so they are synthesized here rather than coming from a tree.
|
||||
private readonly IReadOnlyList<AclScopeNode> _rawHeader = new[]
|
||||
{
|
||||
new AclScopeNode
|
||||
{
|
||||
Key = "ns:raw",
|
||||
DisplayName = "Every raw node",
|
||||
Icon = "🌐",
|
||||
ScopeKind = NodeAclScopeKind.Namespace,
|
||||
ScopeId = NodeAclRealms.Raw,
|
||||
PathLabel = "raw namespace",
|
||||
},
|
||||
};
|
||||
|
||||
private readonly IReadOnlyList<AclScopeNode> _unsHeader = new[]
|
||||
{
|
||||
new AclScopeNode
|
||||
{
|
||||
Key = "ns:uns",
|
||||
DisplayName = "Every UNS node",
|
||||
Icon = "🌐",
|
||||
ScopeKind = NodeAclScopeKind.Namespace,
|
||||
ScopeId = NodeAclRealms.Uns,
|
||||
PathLabel = "uns namespace",
|
||||
},
|
||||
};
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// Reload only on the false→true transition, so a re-render while open does not wipe the
|
||||
// expansion state the operator has built up.
|
||||
if (Visible && !_wasVisible)
|
||||
{
|
||||
_selected = null;
|
||||
_loading = true;
|
||||
_roots = new AclScopeRoots(Array.Empty<AclScopeNode>(), Array.Empty<AclScopeNode>());
|
||||
StateHasChanged();
|
||||
|
||||
_roots = string.IsNullOrWhiteSpace(ClusterId)
|
||||
? new AclScopeRoots(Array.Empty<AclScopeNode>(), Array.Empty<AclScopeNode>())
|
||||
: await Scopes.LoadRootsAsync(ClusterId);
|
||||
|
||||
// The cluster root is the one node worth opening for the operator — everything else is a
|
||||
// decision they should make deliberately.
|
||||
foreach (var root in _roots.Raw) { root.Expanded = false; }
|
||||
_loading = false;
|
||||
}
|
||||
_wasVisible = Visible;
|
||||
}
|
||||
|
||||
private Task<IReadOnlyList<AclScopeNode>> LoadChildrenAsync(AclScopeNode node) =>
|
||||
Scopes.LoadChildrenAsync(node);
|
||||
|
||||
private void Select(AclScopeNode node)
|
||||
{
|
||||
_selected = node;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ConfirmAsync()
|
||||
{
|
||||
if (_selected is null) { return; }
|
||||
await OnPicked.InvokeAsync(_selected);
|
||||
}
|
||||
|
||||
private Task CancelAsync() => OnCancel.InvokeAsync();
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
@* #520 stage 7 — recursive, single-select renderer for the ACL scope picker.
|
||||
|
||||
Deliberately a separate component from RawTree rather than a third mode on it. RawTree already carries
|
||||
the /raw editing tree AND the equipment reference picker; a third mode would put the scope picker's
|
||||
bugs on the same page as device authoring, and this repo has no bUnit to catch that. This renders the
|
||||
unified AclScopeNode, so ONE component covers both the raw and the UNS hierarchy. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
|
||||
|
||||
@foreach (var node in Nodes)
|
||||
{
|
||||
@RenderNode(node, 0)
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The rows to render at this level.</summary>
|
||||
[Parameter, EditorRequired] public IReadOnlyList<AclScopeNode> Nodes { get; set; } = default!;
|
||||
|
||||
/// <summary>The <see cref="AclScopeNode.Key"/> of the currently-selected row, if any.</summary>
|
||||
[Parameter] public string? SelectedKey { get; set; }
|
||||
|
||||
/// <summary>Raised when a selectable row is chosen. Single-select: a grant has exactly one scope.</summary>
|
||||
[Parameter] public EventCallback<AclScopeNode> OnSelect { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Loads one lazy level (the raw side). Wired by the host to <c>IAclScopeService.LoadChildrenAsync</c>;
|
||||
/// when absent, every node is treated as already materialised.
|
||||
/// </summary>
|
||||
[Parameter] public Func<AclScopeNode, Task<IReadOnlyList<AclScopeNode>>>? LoadChildren { get; set; }
|
||||
|
||||
private RenderFragment RenderNode(AclScopeNode node, int depth) => __builder =>
|
||||
{
|
||||
var indent = $"padding-left:{depth * 18}px";
|
||||
var hasExpander = node.HasLazyChildren || node.Children.Count > 0;
|
||||
var selected = node.Selectable && node.Key == SelectedKey;
|
||||
|
||||
<div @key="node.Key" class="d-flex align-items-center gap-1 py-1" style="@indent">
|
||||
@if (hasExpander)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-link p-0" style="width:18px"
|
||||
@onclick="@(() => ToggleAsync(node))">
|
||||
@(node.Expanded ? "▼" : "▶")
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span style="width:18px"></span>
|
||||
}
|
||||
|
||||
@if (node.Selectable)
|
||||
{
|
||||
<button type="button"
|
||||
class="btn btn-sm p-0 px-1 border-0 d-inline-flex align-items-center gap-1 @(selected ? "bg-primary text-white rounded" : "btn-link text-decoration-none")"
|
||||
@onclick="@(() => OnSelect.InvokeAsync(node))">
|
||||
<span aria-hidden="true">@node.Icon</span>
|
||||
<span class="mono small">@node.DisplayName</span>
|
||||
<span class="chip chip-idle ms-1" style="font-size:0.65rem">@node.ScopeKind</span>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Label only — an enterprise heading, or a level with no ACL scope kind behind it. *@
|
||||
<span class="d-inline-flex align-items-center gap-1 text-muted">
|
||||
<span aria-hidden="true">@node.Icon</span>
|
||||
<span class="mono small">@node.DisplayName</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (node.Expanded && node.Loading)
|
||||
{
|
||||
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Loading…
|
||||
</div>
|
||||
}
|
||||
else if (node.Expanded && node.Error is not null)
|
||||
{
|
||||
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">@node.Error</div>
|
||||
}
|
||||
else if (node.Expanded)
|
||||
{
|
||||
@foreach (var child in node.Children)
|
||||
{
|
||||
@RenderNode(child, depth + 1)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private async Task ToggleAsync(AclScopeNode node)
|
||||
{
|
||||
if (node.Loading) { return; }
|
||||
|
||||
node.Expanded = !node.Expanded;
|
||||
|
||||
if (node.Expanded && node.HasLazyChildren && !node.Loaded && LoadChildren is not null)
|
||||
{
|
||||
node.Loading = true;
|
||||
node.Error = null;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var children = await LoadChildren(node);
|
||||
node.Children.Clear();
|
||||
node.Children.AddRange(children);
|
||||
node.Loaded = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
node.Error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
node.Loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user