@page "/clusters/{ClusterId}/acls/new" @page "/clusters/{ClusterId}/acls/{NodeAclId}" @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore @using System.ComponentModel.DataAnnotations @using ZB.MOM.WW.OtOpcUa.AdminUI.Acls @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 @inject IDbContextFactory DbFactory @inject IAclScopeService Scopes @inject NavigationManager Nav

@(IsNew ? "New ACL grant" : "Edit ACL grant") · @ClusterId

Cancel
@if (!_loaded) {

Loading…

} else if (!IsNew && _existing is null) {
ACL @NodeAclId not found.
} else {
Grant
@foreach (var p in _knownPrincipals) { }
@if (string.IsNullOrWhiteSpace(_form.Principal)) { An API key's principal, or the reserved @OpcUaPrincipals.Anonymous principal that an anonymous session authenticates as. Mint keys on /apikeys. } else if (_principal.IsAnonymous) { ✓ The reserved anonymous principal — every session admitted without a key authenticates as this. } else if (_principal.EnabledKeys > 0) { ✓ @_principal.EnabledKeys active API key@(_principal.EnabledKeys == 1 ? "" : "s") @(_principal.DisabledKeys > 0 ? $" (+{_principal.DisabledKeys} revoked or expired)" : "") authenticate@(_principal.EnabledKeys == 1 ? "s" : "") as this principal. } else { ⚠ No active API key uses this principal@(_principal.DisabledKeys > 0 ? " (only revoked or expired ones)" : "") — the grant is valid but currently authorizes nobody. That is fine if the key is still to be minted; otherwise check the spelling. }
@_form.ScopeKind @(string.IsNullOrEmpty(_form.ScopeId) ? "(whole cluster)" : _form.ScopeId)
@(_scope.Ok ? "✓ " : "✗ ")@_scope.Message
Permissions inherit downward: a grant on a device covers every tag beneath it. Picking the scope from the tree is what keeps the kind and the target in step — a kind that disagrees with its target resolves to nothing and authorizes nobody.
@foreach (var bit in PermissionBits) {
}
Bundles:
@if (!string.IsNullOrWhiteSpace(_error)) {
@_error
}
Cancel
@if (!IsNew) { }
} @code { private static readonly NodePermissions[] PermissionBits = [ NodePermissions.Browse, NodePermissions.Read, NodePermissions.Subscribe, NodePermissions.HistoryRead, NodePermissions.WriteOperate, NodePermissions.WriteTune, NodePermissions.WriteConfigure, NodePermissions.AlarmRead, NodePermissions.AlarmAcknowledge, NodePermissions.AlarmConfirm, NodePermissions.AlarmShelve, NodePermissions.MethodCall, ]; [Parameter] public string ClusterId { get; set; } = ""; [Parameter] public string? NodeAclId { get; set; } private bool IsNew => string.IsNullOrEmpty(NodeAclId); private FormModel _form = new(); private NodeAcl? _existing; private bool _loaded; private bool _busy; private string? _error; private bool _pickerVisible; private IReadOnlyList _knownPrincipals = Array.Empty(); private PrincipalUsage _principal; private AclScopeCheck _scope; protected override async Task OnInitializedAsync() { if (!IsNew) { await using var db = await DbFactory.CreateDbContextAsync(); _existing = await db.NodeAcls.AsNoTracking().FirstOrDefaultAsync( a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId); if (_existing is not null) { _form = new FormModel { NodeAclId = _existing.NodeAclId, Principal = _existing.Principal, ScopeKind = _existing.ScopeKind, ScopeId = _existing.ScopeId, PermissionFlags = _existing.PermissionFlags, Notes = _existing.Notes, RowVersion = _existing.RowVersion, }; } } _knownPrincipals = await Scopes.LoadKnownPrincipalsAsync(); await RefreshPrincipalAsync(); await RefreshScopeAsync(); _loaded = true; } private void OpenPicker() { _pickerVisible = true; StateHasChanged(); } private async Task ScopePicked(AclScopeNode node) { // The picker supplies both halves at once — the pairing is what stops a kind naming one entity // type while its id names another. _form.ScopeKind = node.ScopeKind!.Value; _form.ScopeId = node.ScopeId; _pickerVisible = false; await RefreshScopeAsync(); } private async Task RefreshPrincipalAsync() => _principal = await Scopes.CheckPrincipalAsync(_form.Principal ?? ""); private async Task RefreshScopeAsync() => _scope = await Scopes.CheckScopeAsync(ClusterId, _form.ScopeKind, _form.ScopeId); private async Task SubmitAsync() { _busy = true; _error = null; try { // Re-check rather than trust the cached verdict: an existing row may have been loaded with a // scope whose entity has since been deleted, and the operator may never have touched the // picker. Blocking here is what stops the page saving a grant the deployment would reject. await RefreshScopeAsync(); if (!_scope.Ok) { _error = $"Scope: {_scope.Message}"; return; } await using var db = await DbFactory.CreateDbContextAsync(); if (IsNew) { if (await db.NodeAcls.AnyAsync(a => a.NodeAclId == _form.NodeAclId)) { _error = $"ACL '{_form.NodeAclId}' already exists."; return; } db.NodeAcls.Add(new NodeAcl { NodeAclId = _form.NodeAclId, ClusterId = ClusterId, Principal = _form.Principal, ScopeKind = _form.ScopeKind, ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId, PermissionFlags = _form.PermissionFlags, Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes, }); } else { var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId); if (entity is null) { _error = "Row no longer exists."; return; } db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; entity.Principal = _form.Principal; entity.ScopeKind = _form.ScopeKind; entity.ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId; entity.PermissionFlags = _form.PermissionFlags; entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes; } await db.SaveChangesAsync(); Nav.NavigateTo($"/clusters/{ClusterId}/acls"); } catch (DbUpdateConcurrencyException) { _error = "Another user changed this ACL while you were editing."; } catch (Exception ex) { _error = ex.Message; } finally { _busy = false; } } private async Task DeleteAsync() { if (IsNew) return; _busy = true; _error = null; try { await using var db = await DbFactory.CreateDbContextAsync(); var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId); if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/acls"); return; } db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; db.NodeAcls.Remove(entity); await db.SaveChangesAsync(); Nav.NavigateTo($"/clusters/{ClusterId}/acls"); } catch (DbUpdateConcurrencyException) { _error = "Another user changed this ACL while you were viewing it."; } catch (Exception ex) { _error = ex.Message; } finally { _busy = false; } } private sealed class FormModel { [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string NodeAclId { get; set; } = ""; [Required] public string Principal { get; set; } = ""; public NodeAclScopeKind ScopeKind { get; set; } = NodeAclScopeKind.Cluster; public string? ScopeId { get; set; } public NodePermissions PermissionFlags { get; set; } = NodePermissions.None; public string? Notes { get; set; } public byte[] RowVersion { get; set; } = []; public bool HasPerm(NodePermissions bit) => PermissionFlags.HasFlag(bit); public void SetPerm(NodePermissions bit, bool on) { if (on) PermissionFlags |= bit; else PermissionFlags &= ~bit; } } }