The Admin-003 fix gated every SignalR hub with [Authorize], but the server-side
Blazor HubConnection clients had no way to authenticate: the browser's HttpOnly
auth cookie is not reachable from the interactive circuit, so every hub negotiate
returned 401 and the Admin live-update feature was non-functional app-wide
(silently degraded on Hosts/ScriptLog, fatal on the cluster pages).
Introduce a token-based hub auth path:
- HubTokenService mints/validates short-lived tokens using ASP.NET Core Data
Protection (the same primitive that protects the auth cookie — no signing-key
management, no new packages). Tokens carry the user's name + roles.
- HubTokenAuthenticationHandler is a custom "HubToken" auth scheme that reads the
token from the Authorization: Bearer header (negotiate) or the access_token
query parameter (WebSocket upgrade).
- The "HubClients" authorization policy runs both the cookie and HubToken
schemes; the hub endpoints use RequireAuthorization("HubClients").
- AdminHubConnectionFactory builds hub connections with an AccessTokenProvider
that mints a fresh token for the circuit's authenticated user on every
(re)connect. All six hub-consuming pages now resolve connections through it.
Hub negotiate now returns 200 and the WebSocket upgrades (101); live updates
work. The best-effort try/catch guards added previously are kept as defence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
296 lines
12 KiB
Plaintext
296 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
|
|
@inject AdminHubConnectionFactory HubFactory
|
|
@implements IAsyncDisposable
|
|
|
|
<div class="d-flex justify-content-between mb-3">
|
|
<h4 class="panel-head">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
|
|
{
|
|
<section class="panel rise" style="animation-delay:.02s">
|
|
<div class="panel-head">Grants</div>
|
|
<div class="table-wrap">
|
|
<table class="data-table">
|
|
<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><span class="mono">@(a.ScopeId ?? "-")</span></td>
|
|
<td><span class="mono">@a.PermissionFlags</span></td>
|
|
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => RevokeAsync(a.NodeAclRowId)">Revoke</button></td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
@* Probe-this-permission — task #196 slice 1 *@
|
|
<section class="panel rise" style="animation-delay:.08s">
|
|
<div class="panel-head">
|
|
Probe this permission
|
|
<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="chip chip-ok">Granted</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="chip chip-bad">Denied</span>
|
|
}
|
|
<span class="small ms-2">
|
|
Required <span class="mono">@_probeResult.Required</span>,
|
|
Effective <span class="mono">@_probeResult.Effective</span>
|
|
</span>
|
|
</span>
|
|
}
|
|
</div>
|
|
@if (_probeResult is not null && _probeResult.Matches.Count > 0)
|
|
{
|
|
<div class="table-wrap mt-3">
|
|
<table class="data-table">
|
|
<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><span class="mono">@m.LdapGroup</span></td>
|
|
<td>@m.Scope</td>
|
|
<td><span class="mono">@m.PermissionFlags</span></td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
else if (_probeResult is not null)
|
|
{
|
|
<div class="mt-2 small text-muted">No matching grants for this (group, scope) — effective permission is <span class="mono">None</span>.</div>
|
|
}
|
|
</div>
|
|
</section>
|
|
|
|
@if (_showForm)
|
|
{
|
|
<section class="panel rise" style="animation-delay:.14s">
|
|
<div class="panel-head">Add grant</div>
|
|
<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 form-control-sm" @bind="_group"/>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Scope kind</label>
|
|
<select class="form-select form-select-sm" @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 form-control-sm" @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 form-select-sm" @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) { <section class="panel notice mt-3">@_error</section> }
|
|
<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>
|
|
</section>
|
|
}
|
|
|
|
@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 = HubFactory.Create("/hubs/fleet");
|
|
_hub.On<NodeAclChangedMessage>("NodeAclChanged", async msg =>
|
|
{
|
|
if (msg.ClusterId != ClusterId || msg.GenerationId != GenerationId) return;
|
|
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
|
await InvokeAsync(StateHasChanged);
|
|
});
|
|
// Best-effort: FleetStatusHub requires an authenticated caller, and the server-side
|
|
// HubConnection cannot forward the browser auth cookie — swallow connect failures so
|
|
// the tab still renders. Live ACL-change updates degrade.
|
|
try
|
|
{
|
|
await _hub.StartAsync();
|
|
await _hub.SendAsync("SubscribeCluster", ClusterId);
|
|
}
|
|
catch
|
|
{
|
|
// best-effort live updates — see comment above
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|