Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor
T
Joseph Doherty 53ede679c3 docs+ui(security): state plainly that node ACLs are not enforced (§8.1, #520)
deferment.md §8.1 asked for a decision: wire IPermissionEvaluator into the node
manager, or say plainly that ACLs are not enforced. Decision: the latter. The
evaluator stays in the tree; #520 tracks the wire-up.

Verifying the claim turned up four things worse than the audit recorded:

- docs/security.md, not ReadWriteOperations.md, was the highest-risk doc. It
  claimed an anonymous session is "default-denie[d] any node a session has no
  ACL grant for". The opposite holds: anonymous can Browse/Read/Subscribe/
  HistoryRead the entire address space, and is refused writes and alarm acks
  only because it carries no roles.
- Three of five documented data-plane role strings do nothing.
  OpcUaDataPlaneRoles declares exactly WriteOperate and AlarmAck; ReadOnly,
  WriteTune and WriteConfigure are compared against nowhere in src/. The doc
  called all five "exact, case-insensitive, and code-true".
- ReadWriteOperations.md was fiction beyond the ACL claims. OnReadValue has
  zero occurrences in src/ — the documented read path did not exist. Reads
  never reach a driver: the node manager is push-model and the SDK serves a
  Read from the cached pushed value. WriteAuthzPolicy, _sourceByFullRef,
  _writeIdempotentByFullRef and IRoleBearer are likewise zero-hit, and
  CapabilityInvoker is not referenced by the OpcUaServer project at all. This
  file is rewritten rather than bannered.
- v2-release-readiness.md claimed a whole enforcement layer shipped —
  FilterBrowseReferences, GateCallMethodRequests, MapCallOperation,
  AuthorizationBootstrap, Node:Authorization:* keys. None exist. Whatever
  landed on the v2 branch did not survive into the shipped tree.

The UI change is the operative one: ClusterAcls.razor and AclEdit.razor now
warn that a grant is saved and shipped in every deployment artifact but never
evaluated, so an operator cannot author a deny rule believing it works.

deferment.md gains a §9 execution log tracking §8 remediation.
2026-07-27 18:36:48 -04:00

242 lines
12 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>This grant will not be enforced.</strong>
It is saved and shipped in every deployment artifact, but the OPC UA server never evaluates
ACL rows — the permission evaluator has no production call site. Nothing you set below will
change what a client can read, write, browse or acknowledge.
<br />
Real access control today is fleet-wide LDAP-group → role mapping
(<span class="mono">Security:Ldap:GroupToRole</span>):
<span class="mono">WriteOperate</span> to write any tag,
<span class="mono">AlarmAck</span> to acknowledge any alarm. Reads are ungated.
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">LDAP group</label>
<InputText id="grp" @bind-Value="_form.LdapGroup" class="form-control form-control-sm mono"
placeholder="cn=Operators,ou=FleetAdmin,dc=zb,dc=local" />
</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</option>
<option value="@NodeAclScopeKind.Tag">Tag</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>
</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,
LdapGroup = _existing.LdapGroup,
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,
LdapGroup = _form.LdapGroup,
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.LdapGroup = _form.LdapGroup;
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 LdapGroup { 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;
}
}
}