Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor
T
Joseph Doherty 00aa486677 feat(security): enforce node ACLs on read, browse and subscribe (#520 stage 6)
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
2026-08-01 09:22:12 -04:00

288 lines
15 KiB
Plaintext

@page "/clusters/{ClusterId}/acls/new"
@page "/clusters/{ClusterId}/acls/{NodeAclId}"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New ACL grant" : "Edit ACL grant") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="acls" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">ACL <span class="mono">@NodeAclId</span> not found.</section>
}
else
{
<div class="alert alert-warning" 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 />
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>.
</div>
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
<DataAnnotationsValidator />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Grant</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="aid">NodeAclId</label>
<InputText id="aid" @bind-Value="_form.NodeAclId" disabled="@(!IsNew)" class="form-control form-control-sm mono" />
</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" />
<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.
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Permissions</label>
<div>
@foreach (var bit in PermissionBits)
{
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input"
id="perm-@bit"
checked="@_form.HasPerm(bit)"
@onchange="e => _form.SetPerm(bit, (bool)e.Value!)" />
<label class="form-check-label" for="perm-@bit">@bit</label>
</div>
}
</div>
<div class="form-text mt-2">
Bundles:
<button type="button" class="btn btn-sm btn-link p-0 ms-1" @onclick="() => _form.PermissionFlags = NodePermissions.ReadOnly">ReadOnly</button>
&middot;
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Operator">Operator</button>
&middot;
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Engineer">Engineer</button>
&middot;
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Admin">Admin</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<InputTextArea @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div> }
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button>
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary">Cancel</a>
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
</div>
</EditForm>
}
@code {
private static readonly NodePermissions[] PermissionBits =
[
NodePermissions.Browse, NodePermissions.Read, NodePermissions.Subscribe, NodePermissions.HistoryRead,
NodePermissions.WriteOperate, NodePermissions.WriteTune, NodePermissions.WriteConfigure,
NodePermissions.AlarmRead, NodePermissions.AlarmAcknowledge, NodePermissions.AlarmConfirm, NodePermissions.AlarmShelve,
NodePermissions.MethodCall,
];
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? NodeAclId { get; set; }
private bool IsNew => string.IsNullOrEmpty(NodeAclId);
private FormModel _form = new();
private NodeAcl? _existing;
private bool _loaded;
private bool _busy;
private string? _error;
protected override async Task OnInitializedAsync()
{
if (!IsNew)
{
await using var db = await DbFactory.CreateDbContextAsync();
_existing = await db.NodeAcls.AsNoTracking().FirstOrDefaultAsync(
a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId);
if (_existing is not null)
{
_form = new FormModel
{
NodeAclId = _existing.NodeAclId,
Principal = _existing.Principal,
ScopeKind = _existing.ScopeKind,
ScopeId = _existing.ScopeId,
PermissionFlags = _existing.PermissionFlags,
Notes = _existing.Notes,
RowVersion = _existing.RowVersion,
};
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.NodeAcls.AnyAsync(a => a.NodeAclId == _form.NodeAclId))
{ _error = $"ACL '{_form.NodeAclId}' already exists."; return; }
db.NodeAcls.Add(new NodeAcl
{
NodeAclId = _form.NodeAclId,
ClusterId = ClusterId,
Principal = _form.Principal,
ScopeKind = _form.ScopeKind,
ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId,
PermissionFlags = _form.PermissionFlags,
Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes,
});
}
else
{
var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Principal = _form.Principal;
entity.ScopeKind = _form.ScopeKind;
entity.ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId;
entity.PermissionFlags = _form.PermissionFlags;
entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/acls");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this ACL while you were editing."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/acls"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.NodeAcls.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/acls");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this ACL while you were viewing it."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string NodeAclId { get; set; } = "";
[Required] public string Principal { get; set; } = "";
public NodeAclScopeKind ScopeKind { get; set; } = NodeAclScopeKind.Cluster;
public string? ScopeId { get; set; }
public NodePermissions PermissionFlags { get; set; } = NodePermissions.None;
public string? Notes { get; set; }
public byte[] RowVersion { get; set; } = [];
public bool HasPerm(NodePermissions bit) => PermissionFlags.HasFlag(bit);
public void SetPerm(NodePermissions bit, bool on)
{
if (on) PermissionFlags |= bit;
else PermissionFlags &= ~bit;
}
}
}