280 lines
12 KiB
Plaintext
280 lines
12 KiB
Plaintext
@using Microsoft.AspNetCore.SignalR.Client
|
|
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
|
@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
|
|
@inject NavigationManager Nav
|
|
@implements IAsyncDisposable
|
|
|
|
<div class="d-flex justify-content-between mb-3">
|
|
<h4>Access-control grants</h4>
|
|
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add grant</button>
|
|
</div>
|
|
|
|
@if (_acls is null) { <p>Loading…</p> }
|
|
else if (_acls.Count == 0) { <p class="text-muted">No ACL grants in this draft. Publish will result in a cluster with no external access.</p> }
|
|
else
|
|
{
|
|
<table class="table table-sm">
|
|
<thead><tr><th>LDAP group</th><th>Scope</th><th>Scope ID</th><th>Permissions</th><th></th></tr></thead>
|
|
<tbody>
|
|
@foreach (var a in _acls)
|
|
{
|
|
<tr>
|
|
<td>@a.LdapGroup</td>
|
|
<td>@a.ScopeKind</td>
|
|
<td><code>@(a.ScopeId ?? "-")</code></td>
|
|
<td><code>@a.PermissionFlags</code></td>
|
|
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => RevokeAsync(a.NodeAclRowId)">Revoke</button></td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</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">
|
|
<div class="card-body">
|
|
<div class="row g-3">
|
|
<div class="col-md-4">
|
|
<label class="form-label">LDAP group</label>
|
|
<input class="form-control" @bind="_group"/>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Scope kind</label>
|
|
<select class="form-select" @bind="_scopeKind">
|
|
@foreach (var k in Enum.GetValues<NodeAclScopeKind>()) { <option value="@k">@k</option> }
|
|
</select>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Scope ID (empty for Cluster-wide)</label>
|
|
<input class="form-control" @bind="_scopeId"/>
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label">Permissions (bundled presets — per-flag editor in v2.1)</label>
|
|
<select class="form-select" @bind="_preset">
|
|
<option value="Read">Read (Browse + Read)</option>
|
|
<option value="WriteOperate">Read + Write Operate</option>
|
|
<option value="Engineer">Read + Write Tune + Write Configure</option>
|
|
<option value="AlarmAck">Read + Alarm Ack</option>
|
|
<option value="Full">Full (every flag)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
|
<div class="mt-3">
|
|
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
|
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public long GenerationId { get; set; }
|
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
|
|
|
private List<NodeAcl>? _acls;
|
|
private bool _showForm;
|
|
private string _group = string.Empty;
|
|
private NodeAclScopeKind _scopeKind = NodeAclScopeKind.Cluster;
|
|
private string _scopeId = string.Empty;
|
|
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;
|
|
|
|
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() =>
|
|
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
|
|
|
private NodePermissions ResolvePreset() => _preset switch
|
|
{
|
|
"Read" => NodePermissions.Browse | NodePermissions.Read,
|
|
"WriteOperate" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate,
|
|
"Engineer" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteTune | NodePermissions.WriteConfigure,
|
|
"AlarmAck" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge,
|
|
"Full" => unchecked((NodePermissions)(-1)),
|
|
_ => NodePermissions.Browse | NodePermissions.Read,
|
|
};
|
|
|
|
private async Task SaveAsync()
|
|
{
|
|
_error = null;
|
|
if (string.IsNullOrWhiteSpace(_group)) { _error = "LDAP group is required"; return; }
|
|
|
|
var scopeId = _scopeKind == NodeAclScopeKind.Cluster ? null
|
|
: string.IsNullOrWhiteSpace(_scopeId) ? null : _scopeId;
|
|
|
|
if (_scopeKind != NodeAclScopeKind.Cluster && scopeId is null)
|
|
{
|
|
_error = $"ScopeId required for {_scopeKind}";
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await AclSvc.GrantAsync(GenerationId, ClusterId, _group, _scopeKind, scopeId,
|
|
ResolvePreset(), notes: null, CancellationToken.None);
|
|
_group = string.Empty; _scopeId = string.Empty;
|
|
_showForm = false;
|
|
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
|
}
|
|
catch (Exception ex) { _error = ex.Message; }
|
|
}
|
|
|
|
private async Task RevokeAsync(Guid rowId)
|
|
{
|
|
await AclSvc.RevokeAsync(rowId, CancellationToken.None);
|
|
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
|
}
|
|
}
|