AclsTab Probe-this-permission — first of three #196 slices. New /clusters/{ClusterId}/draft/{GenerationId} ACLs-tab gains a probe card above the grant table so operators can ask the trie "if cn=X asks for permission Y on node Z, would it be granted, and which rows contributed?" without shell-ing into the DB. Service thinly wraps the same PermissionTrieBuilder + PermissionTrie.CollectMatches call path the Server's dispatch layer uses at request time, so a probe answer is by construction identical to what the live server would decide. New PermissionProbeService.ProbeAsync(generationId, ldapGroup, NodeScope, requiredFlags) — loads the target generation's NodeAcl rows filtered to the cluster (critical: without the cluster filter, cross-cluster grants leak into the probe which tested false-positive in the unit suite), builds a trie, CollectMatches against the supplied scope + [ldapGroup], ORs the matched-grant flags into Effective, compares to Required. Returns PermissionProbeResult(Granted, Required, Effective, Matches) — Matches carries LdapGroup + Scope + PermissionFlags per matched row so the UI can render the contribution chain. Zero side effects + no audit rows — a failing probe is a question, not a denial. AclsTab.razor gains the probe card at the top (before the New-grant form + grant table): six inputs for ldap group + every NodeScope level (NamespaceId → UnsAreaId → UnsLineId → EquipmentId → TagId — blank fields become null so the trie walks only as deep as the operator specified), a NodePermissions dropdown filtered to skip None, Probe button, green Granted / red Denied badge + Required/Effective bitmask display, and (when matches exist) a small table showing which LdapGroup matched at which level with which flags. Admin csproj adds ProjectReference to Core — the trie + NodeScope live there + were previously Server-only. Five new PermissionProbeServiceTests covering: cluster-level row grants a namespace-level read; no-group-match denies with empty Effective; matching group but insufficient flags (Browse+Read vs WriteOperate required) denies with correct Effective bitmask; cross-cluster grants stay isolated (c2's WriteOperate does NOT leak into c1's probe); generation isolation (gen1's Read-only does NOT let gen2's WriteOperate-requiring probe pass). Admin.Tests 92/92 passing (was 87, +5). Admin builds 0 errors. Remaining #196 slices — SignalR invalidation + draft-diff ACL section — ship in follow-up PRs so the review surface per PR stays tight.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
|
||||
@inject NodeAclService AclSvc
|
||||
@inject PermissionProbeService ProbeSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Access-control grants</h4>
|
||||
@@ -29,6 +31,95 @@ else
|
||||
</table>
|
||||
}
|
||||
|
||||
@* Probe-this-permission — task #196 slice 1 *@
|
||||
<div class="card mt-4 mb-3">
|
||||
<div class="card-header">
|
||||
<strong>Probe this permission</strong>
|
||||
<span class="small text-muted ms-2">
|
||||
Ask the trie "if LDAP group X asks for permission Y on node Z, would it be granted?" —
|
||||
answers the same way the live server does at request time.
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">LDAP group</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeGroup" placeholder="cn=fleet-admin,…"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Namespace</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeNamespaceId" placeholder="ns-1"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">UnsArea</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeUnsAreaId"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">UnsLine</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeUnsLineId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Equipment</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeEquipmentId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Tag</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeTagId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Permission</label>
|
||||
<select class="form-select form-select-sm" @bind="_probePermission">
|
||||
@foreach (var p in Enum.GetValues<NodePermissions>())
|
||||
{
|
||||
if (p == NodePermissions.None) continue;
|
||||
<option value="@p">@p</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RunProbeAsync" disabled="@_probing">Probe</button>
|
||||
@if (_probeResult is not null)
|
||||
{
|
||||
<span class="ms-3">
|
||||
@if (_probeResult.Granted)
|
||||
{
|
||||
<span class="badge bg-success">Granted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">Denied</span>
|
||||
}
|
||||
<span class="small ms-2">
|
||||
Required <code>@_probeResult.Required</code>,
|
||||
Effective <code>@_probeResult.Effective</code>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (_probeResult is not null && _probeResult.Matches.Count > 0)
|
||||
{
|
||||
<table class="table table-sm mt-3 mb-0">
|
||||
<thead><tr><th>LDAP group matched</th><th>Level</th><th>Flags contributed</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var m in _probeResult.Matches)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@m.LdapGroup</code></td>
|
||||
<td>@m.Scope</td>
|
||||
<td><code>@m.PermissionFlags</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else if (_probeResult is not null)
|
||||
{
|
||||
<div class="mt-2 small text-muted">No matching grants for this (group, scope) — effective permission is <code>None</code>.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card">
|
||||
@@ -80,6 +171,40 @@ else
|
||||
private string _preset = "Read";
|
||||
private string? _error;
|
||||
|
||||
// Probe-this-permission state
|
||||
private string _probeGroup = string.Empty;
|
||||
private string _probeNamespaceId = string.Empty;
|
||||
private string _probeUnsAreaId = string.Empty;
|
||||
private string _probeUnsLineId = string.Empty;
|
||||
private string _probeEquipmentId = string.Empty;
|
||||
private string _probeTagId = string.Empty;
|
||||
private NodePermissions _probePermission = NodePermissions.Read;
|
||||
private PermissionProbeResult? _probeResult;
|
||||
private bool _probing;
|
||||
|
||||
private async Task RunProbeAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_probeGroup)) { _probeResult = null; return; }
|
||||
_probing = true;
|
||||
try
|
||||
{
|
||||
var scope = new NodeScope
|
||||
{
|
||||
ClusterId = ClusterId,
|
||||
NamespaceId = NullIfBlank(_probeNamespaceId),
|
||||
UnsAreaId = NullIfBlank(_probeUnsAreaId),
|
||||
UnsLineId = NullIfBlank(_probeUnsLineId),
|
||||
EquipmentId = NullIfBlank(_probeEquipmentId),
|
||||
TagId = NullIfBlank(_probeTagId),
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
_probeResult = await ProbeSvc.ProbeAsync(GenerationId, _probeGroup.Trim(), scope, _probePermission, CancellationToken.None);
|
||||
}
|
||||
finally { _probing = false; }
|
||||
}
|
||||
|
||||
private static string? NullIfBlank(string s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
|
||||
protected override async Task OnParametersSetAsync() =>
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ builder.Services.AddScoped<UnsService>();
|
||||
builder.Services.AddScoped<NamespaceService>();
|
||||
builder.Services.AddScoped<DriverInstanceService>();
|
||||
builder.Services.AddScoped<NodeAclService>();
|
||||
builder.Services.AddScoped<PermissionProbeService>();
|
||||
builder.Services.AddScoped<ReservationService>();
|
||||
builder.Services.AddScoped<DraftValidationService>();
|
||||
builder.Services.AddScoped<AuditLogService>();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Runs an ad-hoc permission probe against a draft or published generation's NodeAcl rows —
|
||||
/// "if LDAP group X asks for permission Y on node Z, would the trie grant it, and which
|
||||
/// rows contributed?" Powers the AclsTab "Probe this permission" form per the #196 sub-slice.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Thin wrapper over <see cref="PermissionTrieBuilder"/> + <see cref="PermissionTrie.CollectMatches"/> —
|
||||
/// the same code path the Server's dispatch layer uses at request time, so a probe result
|
||||
/// is guaranteed to match what the live server would decide. The probe is read-only + has
|
||||
/// no side effects; failing probes do NOT generate audit log rows.
|
||||
/// </remarks>
|
||||
public sealed class PermissionProbeService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="required"/> against the NodeAcl rows of
|
||||
/// <paramref name="generationId"/> for a request by <paramref name="ldapGroup"/> at
|
||||
/// <paramref name="scope"/>. Returns whether the permission would be granted + the list
|
||||
/// of matching grants so the UI can show *why*.
|
||||
/// </summary>
|
||||
public async Task<PermissionProbeResult> ProbeAsync(
|
||||
long generationId,
|
||||
string ldapGroup,
|
||||
NodeScope scope,
|
||||
NodePermissions required,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ldapGroup);
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
|
||||
var rows = await db.NodeAcls.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId && a.ClusterId == scope.ClusterId)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var trie = PermissionTrieBuilder.Build(scope.ClusterId, generationId, rows);
|
||||
var matches = trie.CollectMatches(scope, [ldapGroup]);
|
||||
|
||||
var effective = NodePermissions.None;
|
||||
foreach (var m in matches)
|
||||
effective |= m.PermissionFlags;
|
||||
|
||||
var granted = (effective & required) == required;
|
||||
return new PermissionProbeResult(
|
||||
Granted: granted,
|
||||
Required: required,
|
||||
Effective: effective,
|
||||
Matches: matches);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a <see cref="PermissionProbeService.ProbeAsync"/> call.</summary>
|
||||
public sealed record PermissionProbeResult(
|
||||
bool Granted,
|
||||
NodePermissions Required,
|
||||
NodePermissions Effective,
|
||||
IReadOnlyList<MatchedGrant> Matches);
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user