00aa486677
Completes data-plane enforcement behind Security:Acl:Enabled. Read (every attribute, not just Value), Browse, TranslateBrowsePath, CreateMonitoredItems, SubscribeToEvents and SetMonitoringMode now consult scoped NodeAcl grants. With the flag off nothing changes. Four things went beyond the stage as written, each because leaving it out would have made the feature look like it worked: - Browse needed ancestor visibility. The trie inherits downward, and a client reaches a deep node by browsing the nodes above it, so without this every grant narrower than the cluster was authored, shipped and unreachable. It is a separate non-inheriting marker, NOT a synthesized Browse grant on each ancestor: the additive downward walk would carry such a grant from Plant onto Plant/Siemens/dev9 and every other sibling subtree. Pinned by a test that goes through the evaluator — probing the trie helper directly passes against the broken design, because a sibling is absent from the trie either way. - Revocation had to reach LIVE subscriptions. Every other operation re-asks per request; a subscription is authorized once and then streams, and streaming is how machine consumers actually read this server — so stopping only the re-asking operations would have made revocation close to cosmetic. IDataPlaneAuthoritySource gained a Changed event. A denied item is queued BadUserAccessDenied and then DETACHED from its MonitoredNode2 fan-out, not set MonitoringMode.Disabled. Disabling discards the item's queued notifications, so the client is silenced without ever being told why. The over-the-wire test caught this; the unit-level path reports the item as stood down either way. Detaching is also reversible, so a restored grant resumes the feed with no client action. - The decision cache holds the grant WALK, not the decision. Grants and the trie are fixed for a generation; a key's ExpiresUtc passes with the clock, so caching the whole decision would let an expired key keep working until the next deploy — the stage-5 open-session hole in a subtler form. - TranslateBrowsePath is gated, without which browse filtering is decorative: a client refused a browse asks TranslateBrowsePathsToNodeIds for the same path. Found en route: UserAuthorizationState's staleness ceiling couples two clocks. MembershipResolvedUtc is stamped from DataPlaneAuthorizer's TimeProvider while IsStale is evaluated against TriePermissionEvaluator's; they are the same instance in production and diverge 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 cannot fire was accidentally true; they are now disabled explicitly. Also fixed: ContinuationPoint.NodeToBrowse is the manager HANDLE, not a NodeState. The declared type is object, so the wrong cast compiled cleanly and silently yielded null — skipping the browse source gate entirely. Falsified: disabling the transit marking reddens exactly the three tests that assert transit works; implementing transit as an ancestor grant reddens the sibling-leak test; skipping the fan-out detach reddens the revocation test. Full solution suite green. The stage 8 live gate has NOT run yet. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
140 lines
7.0 KiB
Plaintext
140 lines
7.0 KiB
Plaintext
@page "/clusters/{ClusterId}/acls"
|
|
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
|
@rendermode RenderMode.InteractiveServer
|
|
@using Microsoft.EntityFrameworkCore
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h4 class="mb-0">ACLs · <span class="mono">@ClusterId</span></h4>
|
|
<a href="/clusters/@ClusterId/acls/new" class="btn btn-primary btn-sm">New ACL grant</a>
|
|
</div>
|
|
|
|
<ClusterNav ClusterId="@ClusterId" ActiveTab="acls" />
|
|
|
|
@if (_rows is null)
|
|
{
|
|
<p>Loading…</p>
|
|
}
|
|
else
|
|
{
|
|
<div class="alert alert-warning" 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.
|
|
<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>. A principal with
|
|
no grant at all can connect and see nothing.
|
|
<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
|
|
deployment, not just future requests.
|
|
<br />
|
|
Identity is an <strong>API key</strong>, not LDAP: a client presents an OPC UA UserName token
|
|
with username <span class="mono">apikey</span> and password
|
|
<span class="mono">otua_<keyId>_<secret></span>, and the key resolves to the
|
|
principal named on these rows. Mint keys on the <span class="mono">/apikeys</span> page; LDAP
|
|
now covers AdminUI login only. See <span class="mono">docs/security.md</span>.
|
|
</div>
|
|
|
|
<section class="panel notice rise" style="animation-delay:.02s">
|
|
ACL rows grant principals specific <span class="mono">NodePermissions</span> on a scope
|
|
(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.
|
|
</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>
|
|
}
|
|
else
|
|
{
|
|
<div class="table-wrap">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>NodeAclId</th>
|
|
<th>Principal</th>
|
|
<th>Scope</th>
|
|
<th>Scope target</th>
|
|
<th>Permissions</th>
|
|
<th>Notes</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var a in _rows)
|
|
{
|
|
<tr>
|
|
<td><span class="mono small">@a.NodeAclId</span></td>
|
|
<td><span class="mono">@a.Principal</span></td>
|
|
<td>@a.ScopeKind</td>
|
|
<td><span class="mono small">@(a.ScopeId ?? "—")</span></td>
|
|
<td>
|
|
@foreach (var perm in PermissionChips(a.PermissionFlags))
|
|
{
|
|
<span class="chip chip-idle me-1">@perm</span>
|
|
}
|
|
</td>
|
|
<td class="text-muted small">@(a.Notes ?? "")</td>
|
|
<td><a href="/clusters/@ClusterId/acls/@a.NodeAclId" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public string ClusterId { get; set; } = "";
|
|
private List<NodeAcl>? _rows;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await using var db = await DbFactory.CreateDbContextAsync();
|
|
_rows = await db.NodeAcls.AsNoTracking()
|
|
.Where(a => a.ClusterId == ClusterId)
|
|
.OrderBy(a => a.NodeAclId)
|
|
.ToListAsync();
|
|
}
|
|
|
|
private static IEnumerable<string> PermissionChips(ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodePermissions flags)
|
|
{
|
|
foreach (var v in Enum.GetValues<ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodePermissions>())
|
|
{
|
|
// Skip None (zero) and composite values that aren't single bits.
|
|
var n = (int)v;
|
|
if (n == 0) continue;
|
|
if ((n & (n - 1)) != 0) continue;
|
|
if (flags.HasFlag(v)) yield return v.ToString();
|
|
}
|
|
}
|
|
}
|