Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor
T
Joseph Doherty 4d376f727f refactor(acl): NodeAcl.LdapGroup -> Principal, with the proc that reads it (#520)
Stage 1a of the API-key ACL design. Behaviour-neutral: the trie walker already
matched an opaque string, so only what produces that string changes. No
enforcement is wired by this commit and none of the two live role gates move.

Renamed through the ACL model: NodeAcl.Principal, TrieGrant.Principal,
MatchedGrant.Principal, AuthorizationDecision.Principal,
UserAuthorizationState.Principals, and PermissionTrie.CollectMatches's parameter.
LdapGroupRoleMapping is a DIFFERENT entity that also has an LdapGroup column and
is deliberately untouched — it backs AdminUI login, which keeps LDAP.

The migration is the interesting part. EF scaffolds RenameColumn/RenameIndex, so
rows survive — but it cannot know that dbo.sp_ComputeGenerationDiff checksums
NodeAcl.LdapGroup. That proc is created in V3Initial.StoredProcedures and never
redefined since, and SQL Server does not validate a procedure body when a column
is renamed out from under it: the object keeps reporting healthy and fails at
CALL time with Invalid column name, silently breaking config-drift detection.
Verified against the live dev DB that the deployed proc really does reference the
column. The migration therefore re-emits the proc, as one parameterised template
shared by Up and Down so the two cannot drift.

Prose that describes what is enforced TODAY (fleet-wide LDAP group -> role) is
left alone, because it is still accurate until stage 2 lands.

UserAuthorizationState's refresh machinery is retained unchanged but now carries
a note that it is LDAP-shaped and largely vestigial here: an API-key principal
set cannot change out of band, since keys and ACL rows both ride the deployment
artifact, so the generation stamp rather than the freshness timer is the real
invalidation signal.

41 authorization tests pass; solution builds clean.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 12:17:57 -04:00

112 lines
4.8 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 &middot; <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>These rules are not enforced.</strong>
ACL rows are saved and shipped in every deployment artifact, but the OPC UA server never
evaluates them — the permission evaluator has no production call site. Authoring a grant here
changes nothing about what a client can read or write.
<br />
What <em>is</em> enforced today is coarse and fleet-wide: a client needs the
<span class="mono">WriteOperate</span> role to write any tag and
<span class="mono">AlarmAck</span> to acknowledge any alarm, both mapped from LDAP groups via
<span class="mono">Security:Ldap:GroupToRole</span>. Reads, browses, subscriptions and history
reads are <strong>not</strong> restricted at all. 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
(a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of
fleet-wide LDAP-group → role mapping; ACLs here are the finer-grained per-node 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 — default permissions from the fleet-wide LDAP group mapping apply.</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();
}
}
}