Compare commits
8 Commits
ab-server-
...
diff-acl-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df0d7c2d84 | ||
| 16f4b4acad | |||
|
|
ac63c2cfb2 | ||
| d93dc73978 | |||
|
|
ecc2389ca8 | ||
| 852c710013 | |||
|
|
8ce5791f49 | ||
| 05ddea307b |
20
ci/ab-server.lock.json
Normal file
20
ci/ab-server.lock.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Pinned libplctag release used by tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture. ab_server.exe ships inside the *_tools.zip asset on every GitHub release. See docs/v2/test-data-sources.md §2.CI for the GitHub Actions step that consumes this file.",
|
||||||
|
"repo": "libplctag/libplctag",
|
||||||
|
"tag": "v2.6.16",
|
||||||
|
"published": "2026-03-29",
|
||||||
|
"assets": {
|
||||||
|
"windows-x64": {
|
||||||
|
"file": "libplctag_2.6.16_windows_x64_tools.zip",
|
||||||
|
"sha256": "9b78a3dee73d9cd28ca348c090f453dbe3ad9d07ad6bf42865a9dc3a79bc2232"
|
||||||
|
},
|
||||||
|
"windows-x86": {
|
||||||
|
"file": "libplctag_2.6.16_windows_x86_tools.zip",
|
||||||
|
"sha256": "fdfefd58b266c5da9a1ded1a430985e609289c9e67be2544da7513b668761edf"
|
||||||
|
},
|
||||||
|
"windows-arm64": {
|
||||||
|
"file": "libplctag_2.6.16_windows_arm64_tools.zip",
|
||||||
|
"sha256": "d747728e4c4958bb63b4ac23e1c820c4452e4778dfd7d58f8a0aecd5402d4944"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,22 +196,30 @@ The integration harness at `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTest
|
|||||||
- **`AbServerFixture(AbServerProfile)`** — starts the simulator with the CLI args composed from the profile's `--plc` family + seed-tag set. One fixture instance per family, one simulator process per test case (smoke tier). For larger suites that can share a simulator across several reads/writes, use a `IClassFixture<AbServerFixture>` wrapper per family.
|
- **`AbServerFixture(AbServerProfile)`** — starts the simulator with the CLI args composed from the profile's `--plc` family + seed-tag set. One fixture instance per family, one simulator process per test case (smoke tier). For larger suites that can share a simulator across several reads/writes, use a `IClassFixture<AbServerFixture>` wrapper per family.
|
||||||
- **`KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`** — the four per-family profiles. Drives the simulator's `--plc` mode + the preseed `--tag name:type[:size]` set. Micro800 + GuardLogix fall back to `controllogix` under the hood because ab_server has no dedicated mode for them — the driver-side family profile still enforces the narrower connection shape / safety classification separately.
|
- **`KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`** — the four per-family profiles. Drives the simulator's `--plc` mode + the preseed `--tag name:type[:size]` set. Micro800 + GuardLogix fall back to `controllogix` under the hood because ab_server has no dedicated mode for them — the driver-side family profile still enforces the narrower connection shape / safety classification separately.
|
||||||
|
|
||||||
**CI step (intended — fleet-ops to wire):**
|
**Pinned version** (recorded in `ci/ab-server.lock.json` so drift is one-file visible):
|
||||||
|
|
||||||
|
- `libplctag` **v2.6.16** (published 2026-03-29) — `ab_server.exe` ships inside the `_tools.zip` asset alongside `plctag.dll` + two `list_tags_*` helpers.
|
||||||
|
- Windows x64: `libplctag_2.6.16_windows_x64_tools.zip` — SHA256 `9b78a3dee73d9cd28ca348c090f453dbe3ad9d07ad6bf42865a9dc3a79bc2232`
|
||||||
|
- Windows x86: `libplctag_2.6.16_windows_x86_tools.zip` — SHA256 `fdfefd58b266c5da9a1ded1a430985e609289c9e67be2544da7513b668761edf`
|
||||||
|
- Windows ARM64: `libplctag_2.6.16_windows_arm64_tools.zip` — SHA256 `d747728e4c4958bb63b4ac23e1c820c4452e4778dfd7d58f8a0aecd5402d4944`
|
||||||
|
|
||||||
|
**CI step:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# GitHub Actions step placed before `dotnet test`:
|
# GitHub Actions step placed before `dotnet test`:
|
||||||
- name: Fetch ab_server
|
- name: Fetch ab_server (libplctag v2.6.16)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$ver = '<pinned libplctag release tag>'
|
$pin = Get-Content ci/ab-server.lock.json | ConvertFrom-Json
|
||||||
$url = "https://github.com/libplctag/libplctag/releases/download/$ver/ab_server-windows-x64.zip"
|
$asset = $pin.assets.'windows-x64' # swap to windows-x86 / windows-arm64 on non-x64 runners
|
||||||
Invoke-WebRequest $url -OutFile $env:RUNNER_TEMP/ab_server.zip
|
$url = "https://github.com/libplctag/libplctag/releases/download/$($pin.tag)/$($asset.file)"
|
||||||
# SHA256 check against a pinned value recorded in this repo's CI lockfile — drift = fail closed
|
$zip = Join-Path $env:RUNNER_TEMP 'libplctag-tools.zip'
|
||||||
$expected = '<pinned sha256>'
|
Invoke-WebRequest $url -OutFile $zip
|
||||||
$actual = (Get-FileHash -Algorithm SHA256 $env:RUNNER_TEMP/ab_server.zip).Hash
|
$actual = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower()
|
||||||
if ($expected -ne $actual) { throw "ab_server hash mismatch" }
|
if ($actual -ne $asset.sha256) { throw "libplctag tools SHA256 mismatch: expected $($asset.sha256), got $actual" }
|
||||||
Expand-Archive $env:RUNNER_TEMP/ab_server.zip -DestinationPath $env:RUNNER_TEMP/ab_server
|
$dest = Join-Path $env:RUNNER_TEMP 'libplctag-tools'
|
||||||
echo "$env:RUNNER_TEMP/ab_server" >> $env:GITHUB_PATH
|
Expand-Archive $zip -DestinationPath $dest
|
||||||
|
Add-Content $env:GITHUB_PATH $dest
|
||||||
```
|
```
|
||||||
|
|
||||||
The fixture's `LocateBinary()` picks the binary up off PATH so the C# harness doesn't own the download — CI YAML is the right place for version pinning + hash verification. Developer workstations install the binary once from source (`cmake + make ab_server` under a libplctag clone) and the same fixture works identically.
|
The fixture's `LocateBinary()` picks the binary up off PATH so the C# harness doesn't own the download — CI YAML is the right place for version pinning + hash verification. Developer workstations install the binary once from source (`cmake + make ab_server` under a libplctag clone) and the same fixture works identically.
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
@using Microsoft.AspNetCore.SignalR.Client
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
|
||||||
@inject NodeAclService AclSvc
|
@inject NodeAclService AclSvc
|
||||||
|
@inject PermissionProbeService ProbeSvc
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<div class="d-flex justify-content-between mb-3">
|
<div class="d-flex justify-content-between mb-3">
|
||||||
<h4>Access-control grants</h4>
|
<h4>Access-control grants</h4>
|
||||||
@@ -29,6 +35,95 @@ else
|
|||||||
</table>
|
</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)
|
@if (_showForm)
|
||||||
{
|
{
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -80,6 +175,64 @@ else
|
|||||||
private string _preset = "Read";
|
private string _preset = "Read";
|
||||||
private string? _error;
|
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;
|
||||||
|
|
||||||
|
private HubConnection? _hub;
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender || _hub is not null) return;
|
||||||
|
_hub = new HubConnectionBuilder()
|
||||||
|
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status"))
|
||||||
|
.WithAutomaticReconnect()
|
||||||
|
.Build();
|
||||||
|
_hub.On<NodeAclChangedMessage>("NodeAclChanged", async msg =>
|
||||||
|
{
|
||||||
|
if (msg.ClusterId != ClusterId || msg.GenerationId != GenerationId) return;
|
||||||
|
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
});
|
||||||
|
await _hub.StartAsync();
|
||||||
|
await _hub.SendAsync("SubscribeCluster", ClusterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; }
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync() =>
|
protected override async Task OnParametersSetAsync() =>
|
||||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ else
|
|||||||
new SectionDef("Equipment", "Equipment", "UNS level-5 rows + identification fields"),
|
new SectionDef("Equipment", "Equipment", "UNS level-5 rows + identification fields"),
|
||||||
new SectionDef("Tag", "Tags", "Per-device tag definitions + poll-group binding"),
|
new SectionDef("Tag", "Tags", "Per-device tag definitions + poll-group binding"),
|
||||||
new SectionDef("UnsLine", "UNS structure", "Site / Area / Line hierarchy (proc-extension pending)"),
|
new SectionDef("UnsLine", "UNS structure", "Site / Area / Line hierarchy (proc-extension pending)"),
|
||||||
new SectionDef("NodeAcl", "ACLs", "LDAP-group → node-scope permission grants (proc-extension pending)"),
|
new SectionDef("NodeAcl", "ACLs", "LDAP-group → node-scope permission grants (logical id = LdapGroup|ScopeKind|ScopeId)"),
|
||||||
};
|
};
|
||||||
|
|
||||||
private List<DiffRow>? _rows;
|
private List<DiffRow>? _rows;
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
@page "/role-grants"
|
@page "/role-grants"
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.SignalR.Client
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Services
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Services
|
||||||
@inject ILdapGroupRoleMappingService RoleSvc
|
@inject ILdapGroupRoleMappingService RoleSvc
|
||||||
@inject ClusterService ClusterSvc
|
@inject ClusterService ClusterSvc
|
||||||
|
@inject AclChangeNotifier Notifier
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<h1 class="mb-4">LDAP group → Admin role grants</h1>
|
<h1 class="mb-4">LDAP group → Admin role grants</h1>
|
||||||
|
|
||||||
@@ -147,6 +153,7 @@ else
|
|||||||
Notes = string.IsNullOrWhiteSpace(_notes) ? null : _notes,
|
Notes = string.IsNullOrWhiteSpace(_notes) ? null : _notes,
|
||||||
};
|
};
|
||||||
await RoleSvc.CreateAsync(row, CancellationToken.None);
|
await RoleSvc.CreateAsync(row, CancellationToken.None);
|
||||||
|
await Notifier.NotifyRoleGrantsChangedAsync(CancellationToken.None);
|
||||||
_showForm = false;
|
_showForm = false;
|
||||||
await ReloadAsync();
|
await ReloadAsync();
|
||||||
}
|
}
|
||||||
@@ -156,6 +163,30 @@ else
|
|||||||
private async Task DeleteAsync(Guid id)
|
private async Task DeleteAsync(Guid id)
|
||||||
{
|
{
|
||||||
await RoleSvc.DeleteAsync(id, CancellationToken.None);
|
await RoleSvc.DeleteAsync(id, CancellationToken.None);
|
||||||
|
await Notifier.NotifyRoleGrantsChangedAsync(CancellationToken.None);
|
||||||
await ReloadAsync();
|
await ReloadAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private HubConnection? _hub;
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender || _hub is not null) return;
|
||||||
|
_hub = new HubConnectionBuilder()
|
||||||
|
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status"))
|
||||||
|
.WithAutomaticReconnect()
|
||||||
|
.Build();
|
||||||
|
_hub.On<RoleGrantsChangedMessage>("RoleGrantsChanged", async _ =>
|
||||||
|
{
|
||||||
|
await ReloadAsync();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
});
|
||||||
|
await _hub.StartAsync();
|
||||||
|
await _hub.SendAsync("SubscribeFleet");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ builder.Services.AddScoped<UnsService>();
|
|||||||
builder.Services.AddScoped<NamespaceService>();
|
builder.Services.AddScoped<NamespaceService>();
|
||||||
builder.Services.AddScoped<DriverInstanceService>();
|
builder.Services.AddScoped<DriverInstanceService>();
|
||||||
builder.Services.AddScoped<NodeAclService>();
|
builder.Services.AddScoped<NodeAclService>();
|
||||||
|
builder.Services.AddScoped<PermissionProbeService>();
|
||||||
|
builder.Services.AddScoped<AclChangeNotifier>();
|
||||||
builder.Services.AddScoped<ReservationService>();
|
builder.Services.AddScoped<ReservationService>();
|
||||||
builder.Services.AddScoped<DraftValidationService>();
|
builder.Services.AddScoped<DraftValidationService>();
|
||||||
builder.Services.AddScoped<AuditLogService>();
|
builder.Services.AddScoped<AuditLogService>();
|
||||||
|
|||||||
49
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AclChangeNotifier.cs
Normal file
49
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AclChangeNotifier.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin SignalR push helper for ACL + role-grant invalidation — slice 2 of task #196.
|
||||||
|
/// Lets the Admin services + razor pages invalidate connected peers' views without each
|
||||||
|
/// one having to know the hub wiring. Two message kinds: <c>NodeAclChanged</c> (cluster-scoped)
|
||||||
|
/// and <c>RoleGrantsChanged</c> (fleet-wide — role mappings cross cluster boundaries).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Intentionally fire-and-forget — a failed hub send doesn't rollback the DB write that
|
||||||
|
/// triggered it. Worst-case an operator sees stale data until their next poll or manual
|
||||||
|
/// refresh; better than a transient hub blip blocking the authoritative write path.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AclChangeNotifier(IHubContext<FleetStatusHub> fleetHub, ILogger<AclChangeNotifier> logger)
|
||||||
|
{
|
||||||
|
public async Task NotifyNodeAclChangedAsync(string clusterId, long generationId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var msg = new NodeAclChangedMessage(ClusterId: clusterId, GenerationId: generationId, ObservedAtUtc: DateTime.UtcNow);
|
||||||
|
await fleetHub.Clients.Group(FleetStatusHub.GroupName(clusterId))
|
||||||
|
.SendAsync("NodeAclChanged", msg, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "NodeAclChanged push failed for cluster {ClusterId} gen {GenerationId}", clusterId, generationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NotifyRoleGrantsChangedAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var msg = new RoleGrantsChangedMessage(ObservedAtUtc: DateTime.UtcNow);
|
||||||
|
await fleetHub.Clients.Group(FleetStatusHub.FleetGroup)
|
||||||
|
.SendAsync("RoleGrantsChanged", msg, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "RoleGrantsChanged push failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record NodeAclChangedMessage(string ClusterId, long GenerationId, DateTime ObservedAtUtc);
|
||||||
|
public sealed record RoleGrantsChangedMessage(DateTime ObservedAtUtc);
|
||||||
@@ -5,7 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
public sealed class NodeAclService(OtOpcUaConfigDbContext db)
|
public sealed class NodeAclService(OtOpcUaConfigDbContext db, AclChangeNotifier? notifier = null)
|
||||||
{
|
{
|
||||||
public Task<List<NodeAcl>> ListAsync(long generationId, CancellationToken ct) =>
|
public Task<List<NodeAcl>> ListAsync(long generationId, CancellationToken ct) =>
|
||||||
db.NodeAcls.AsNoTracking()
|
db.NodeAcls.AsNoTracking()
|
||||||
@@ -31,6 +31,10 @@ public sealed class NodeAclService(OtOpcUaConfigDbContext db)
|
|||||||
};
|
};
|
||||||
db.NodeAcls.Add(acl);
|
db.NodeAcls.Add(acl);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
if (notifier is not null)
|
||||||
|
await notifier.NotifyNodeAclChangedAsync(clusterId, draftId, ct);
|
||||||
|
|
||||||
return acl;
|
return acl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,5 +44,8 @@ public sealed class NodeAclService(OtOpcUaConfigDbContext db)
|
|||||||
if (row is null) return;
|
if (row is null) return;
|
||||||
db.NodeAcls.Remove(row);
|
db.NodeAcls.Remove(row);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
if (notifier is not null)
|
||||||
|
await notifier.NotifyNodeAclChangedAsync(row.ClusterId, row.GenerationId, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extends <c>dbo.sp_ComputeGenerationDiff</c> to emit <c>NodeAcl</c> rows alongside the
|
||||||
|
/// existing Namespace/DriverInstance/Equipment/Tag output — closes the final slice of
|
||||||
|
/// task #196 (DiffViewer ACL section). Logical id for NodeAcl is a composite
|
||||||
|
/// <c>LdapGroup|ScopeKind|ScopeId</c> triple so a Change row surfaces whether the grant
|
||||||
|
/// shifted permissions, moved scope, or was added/removed outright.
|
||||||
|
/// </summary>
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExtendComputeGenerationDiffWithNodeAcl : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(Procs.ComputeGenerationDiffV1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class Procs
|
||||||
|
{
|
||||||
|
/// <summary>V2 — adds the NodeAcl section to the diff output.</summary>
|
||||||
|
public const string ComputeGenerationDiffV2 = @"
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||||
|
@FromGenerationId bigint,
|
||||||
|
@ToGenerationId bigint
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
|
||||||
|
|
||||||
|
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
-- NodeAcl section. Logical id is the (LdapGroup, ScopeKind, ScopeId) triple so the diff
|
||||||
|
-- distinguishes same row with new permissions (Modified via CHECKSUM on PermissionFlags + Notes)
|
||||||
|
-- from a scope move (which surfaces as Added + Removed of different logical ids).
|
||||||
|
WITH f AS (
|
||||||
|
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||||
|
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||||
|
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (
|
||||||
|
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||||
|
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||||
|
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||||
|
DROP TABLE #diff;
|
||||||
|
END
|
||||||
|
";
|
||||||
|
|
||||||
|
/// <summary>V1 — exact proc shipped in migration 20260417215224_StoredProcedures. Restored on Down().</summary>
|
||||||
|
public const string ComputeGenerationDiffV1 = @"
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||||
|
@FromGenerationId bigint,
|
||||||
|
@ToGenerationId bigint
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16));
|
||||||
|
|
||||||
|
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||||
|
DROP TABLE #diff;
|
||||||
|
END
|
||||||
|
";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
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.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class PermissionProbeServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_Grants_When_ClusterLevelRow_CoversRequiredFlag()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedAcl(ctx, gen: 1, cluster: "c1",
|
||||||
|
scopeKind: NodeAclScopeKind.Cluster, scopeId: null,
|
||||||
|
group: "cn=operators", flags: NodePermissions.Browse | NodePermissions.Read);
|
||||||
|
var svc = new PermissionProbeService(ctx);
|
||||||
|
|
||||||
|
var result = await svc.ProbeAsync(
|
||||||
|
generationId: 1,
|
||||||
|
ldapGroup: "cn=operators",
|
||||||
|
scope: new NodeScope { ClusterId = "c1", NamespaceId = "ns-1", Kind = NodeHierarchyKind.Equipment },
|
||||||
|
required: NodePermissions.Read,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.Granted.ShouldBeTrue();
|
||||||
|
result.Matches.Count.ShouldBe(1);
|
||||||
|
result.Matches[0].LdapGroup.ShouldBe("cn=operators");
|
||||||
|
result.Matches[0].Scope.ShouldBe(NodeAclScopeKind.Cluster);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_Denies_When_NoGroupMatches()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read);
|
||||||
|
var svc = new PermissionProbeService(ctx);
|
||||||
|
|
||||||
|
var result = await svc.ProbeAsync(1, "cn=random-group",
|
||||||
|
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||||
|
NodePermissions.Read, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Granted.ShouldBeFalse();
|
||||||
|
result.Matches.ShouldBeEmpty();
|
||||||
|
result.Effective.ShouldBe(NodePermissions.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_Denies_When_Effective_Missing_RequiredFlag()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Browse | NodePermissions.Read);
|
||||||
|
var svc = new PermissionProbeService(ctx);
|
||||||
|
|
||||||
|
var result = await svc.ProbeAsync(1, "cn=operators",
|
||||||
|
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||||
|
required: NodePermissions.WriteOperate,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.Granted.ShouldBeFalse();
|
||||||
|
result.Effective.ShouldBe(NodePermissions.Browse | NodePermissions.Read);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_Ignores_Rows_From_OtherClusters()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read);
|
||||||
|
SeedAcl(ctx, 1, "c2", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.WriteOperate);
|
||||||
|
var svc = new PermissionProbeService(ctx);
|
||||||
|
|
||||||
|
var c1Result = await svc.ProbeAsync(1, "cn=operators",
|
||||||
|
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||||
|
NodePermissions.WriteOperate, CancellationToken.None);
|
||||||
|
|
||||||
|
c1Result.Granted.ShouldBeFalse("c2's WriteOperate grant must NOT leak into c1's probe");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_UsesOnlyRows_From_Specified_Generation()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedAcl(ctx, gen: 1, cluster: "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read);
|
||||||
|
SeedAcl(ctx, gen: 2, cluster: "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.WriteOperate);
|
||||||
|
var svc = new PermissionProbeService(ctx);
|
||||||
|
|
||||||
|
var gen1 = await svc.ProbeAsync(1, "cn=operators",
|
||||||
|
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||||
|
NodePermissions.WriteOperate, CancellationToken.None);
|
||||||
|
var gen2 = await svc.ProbeAsync(2, "cn=operators",
|
||||||
|
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||||
|
NodePermissions.WriteOperate, CancellationToken.None);
|
||||||
|
|
||||||
|
gen1.Granted.ShouldBeFalse();
|
||||||
|
gen2.Granted.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedAcl(
|
||||||
|
OtOpcUaConfigDbContext ctx, long gen, string cluster,
|
||||||
|
NodeAclScopeKind scopeKind, string? scopeId, string group, NodePermissions flags)
|
||||||
|
{
|
||||||
|
ctx.NodeAcls.Add(new NodeAcl
|
||||||
|
{
|
||||||
|
NodeAclRowId = Guid.NewGuid(),
|
||||||
|
NodeAclId = $"acl-{Guid.NewGuid():N}"[..16],
|
||||||
|
GenerationId = gen,
|
||||||
|
ClusterId = cluster,
|
||||||
|
LdapGroup = group,
|
||||||
|
ScopeKind = scopeKind,
|
||||||
|
ScopeId = scopeId,
|
||||||
|
PermissionFlags = flags,
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OtOpcUaConfigDbContext NewContext()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new OtOpcUaConfigDbContext(opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user