feat(security): author ACL grants from the real tree, not free text (#520 stage 7)
The last stage of #520. Enforcement landed in stages 5-6; this closes the authoring end, where a grant could be saved, deployed and sealed green while resolving to nothing. Scope kind and scope target are now chosen TOGETHER from a picker over the cluster's raw and UNS trees (AclScopePickerModal -> AclScopeTree, backed by IAclScopeService). They were previously an unrelated dropdown and a free-text box, so "RawDevice + a TagId" was a well-formed row. Because the trees are rooted at the grant's own cluster and each node supplies its own kind, NodeAclScopeCrossCluster, NodeAclScopeIdUnexpected, NodeAclScopeIdMissing and NodeAclScopeKindRetired all become unauthorable by construction. CheckScopeAsync mirrors DraftValidator.ValidateNodeAclScopes rule for rule and blocks the save when a scope resolves to nothing - re-checked on submit, not trusted from form load, because a row whose entity was deleted since is exactly where the operator never touches the picker. The list page marks such rows unresolved with the reason. The principal field suggests known principals and reports how many live keys reach the one typed; reaching nobody warns rather than blocks, since authoring ahead of minting is legitimate. Deliberate departures from the plan line, all recorded in the design doc: - The banners are reshaped, not removed. "Not enforced" stopped being true at stage 6, but enforcement is still a per-node flag the AdminUI cannot see, so a narrow grant really does confer nothing on a default-configured node. - A Cluster row produces a NULL ScopeId. The deploy gate rejects one that carries an id, so filling in the cluster id would author undeployable rows. - AclScopeTree is a new component rather than a third mode on RawTree, which already carries the /raw editing tree and the equipment reference picker. - UNS tags are not offered: a UNS signal is a UnsTagReference, and a Tag grant already covers the raw node and every UNS projection of it. Also fixed: the ACL editor had a DataAnnotationsValidator and no ValidationSummary, so an invalid NodeAclId made Create a silent no-op. Tests: 30 in AdminUI.Tests/Acls, falsified with six production mutations - each turned exactly the intended tests red and no others. Live gate PASSED on docker-dev, 11 legs; both central containers asserted on the same image by container id. The pair that matters is legs 8 and 11: the page and the deploy gate reject the same row with the same reason. Legs 5 and 9 are the positive controls - without them the gate passes against a page that refuses everything. Not covered: UNS-realm scope selection (SITE-A has no UNS areas). Rig restored to zero ACL rows. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -5,10 +5,13 @@
|
||||
@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<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject IAclScopeService Scopes
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -28,47 +31,35 @@ else if (!IsNew && _existing is null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>What this grant does depends on <span class="mono">Security:Acl:Enabled</span> on the
|
||||
node serving the data.</strong> That is per-node configuration, so this page cannot tell you
|
||||
which mode a given node is in — check the node's own settings.
|
||||
<br />
|
||||
<strong>With it off (the default):</strong> saved with scope kind
|
||||
<span class="mono">Cluster</span> this grant confers a <em>server-wide</em> role on its
|
||||
principal — <span class="mono">WriteOperate</span> → writing any tag value;
|
||||
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
|
||||
(acknowledge / confirm / shelve). Saved with any narrower scope
|
||||
(<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
|
||||
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
|
||||
<span class="mono">Tag</span>, <span class="mono">Raw*</span>) it is stored and shipped in every
|
||||
deployment artifact but <strong>confers nothing</strong>. That under-granting is deliberate:
|
||||
honouring its flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on one
|
||||
equipment" into server-wide write authority.
|
||||
<br />
|
||||
<strong>With it on:</strong> the scope is honoured, and the scoped decision <em>replaces</em> the
|
||||
server-wide role rather than adding to it. <strong>Every data-plane operation is gated</strong> —
|
||||
<span class="mono">Read</span>, <span class="mono">Browse</span>,
|
||||
<span class="mono">Subscribe</span>, <span class="mono">WriteOperate</span>,
|
||||
<span class="mono">HistoryRead</span> and the alarm methods, which separate into
|
||||
<span class="mono">AlarmAcknowledge</span> / <span class="mono">AlarmConfirm</span> /
|
||||
<span class="mono">AlarmShelve</span>. So a grant that omits
|
||||
<span class="mono">Read</span> denies reads, and one that omits
|
||||
<span class="mono">Browse</span> hides the node from navigation.
|
||||
<br />
|
||||
<span class="mono">AlarmShelve</span>. So a grant that omits <span class="mono">Read</span>
|
||||
denies reads, and one that omits <span class="mono">Browse</span> hides the node from navigation.
|
||||
Granting <span class="mono">Browse</span> on a node also makes the folders <em>above</em> it
|
||||
browsable — otherwise the grant could never be navigated to — so it reveals their names.
|
||||
Revoking this grant stops any live subscription it was authorizing, on the next deployment.
|
||||
<br />
|
||||
<strong>Principal</strong> is an API-key principal — or the reserved
|
||||
<span class="mono">anonymous</span> principal — not an LDAP group: a client presents an OPC UA
|
||||
UserName token with username <span class="mono">apikey</span> and password
|
||||
<span class="mono">otua_<keyId>_<secret></span>. Mint keys on the
|
||||
<span class="mono">/apikeys</span> page; LDAP now covers AdminUI login only.
|
||||
See <span class="mono">docs/security.md</span>.
|
||||
<strong>With it off (the default):</strong> only a <span class="mono">Cluster</span>-scoped grant
|
||||
does anything, and it confers a <em>server-wide</em> role on its principal —
|
||||
<span class="mono">WriteOperate</span> → writing any tag value;
|
||||
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods. Any narrower scope is
|
||||
stored and shipped but <strong>confers nothing</strong>. That under-granting is deliberate:
|
||||
honouring a grant's flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on
|
||||
one equipment" into server-wide write authority.
|
||||
</div>
|
||||
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="alert alert-danger" />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Grant</div>
|
||||
<div style="padding:1rem">
|
||||
@@ -79,49 +70,71 @@ else
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="grp">Principal</label>
|
||||
<InputText id="grp" @bind-Value="_form.Principal" class="form-control form-control-sm mono"
|
||||
placeholder="scadabridge-dcl" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="scope">Scope kind</label>
|
||||
<InputSelect id="scope" @bind-Value="_form.ScopeKind" class="form-select form-select-sm">
|
||||
<option value="@NodeAclScopeKind.Cluster">Cluster</option>
|
||||
<option value="@NodeAclScopeKind.Namespace">Namespace</option>
|
||||
<option value="@NodeAclScopeKind.UnsArea">UnsArea</option>
|
||||
<option value="@NodeAclScopeKind.UnsLine">UnsLine</option>
|
||||
<option value="@NodeAclScopeKind.Equipment">Equipment</option>
|
||||
<option value="@NodeAclScopeKind.FolderSegment">FolderSegment (retired — do not author)</option>
|
||||
<option value="@NodeAclScopeKind.Tag">Tag</option>
|
||||
<option value="@NodeAclScopeKind.RawFolder">RawFolder</option>
|
||||
<option value="@NodeAclScopeKind.RawDriver">RawDriver</option>
|
||||
<option value="@NodeAclScopeKind.RawDevice">RawDevice</option>
|
||||
<option value="@NodeAclScopeKind.RawTagGroup">RawTagGroup</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="target">Scope target ID</label>
|
||||
<InputText id="target" @bind-Value="_form.ScopeId" class="form-control form-control-sm mono"
|
||||
placeholder="Leave blank for cluster-wide" />
|
||||
<input id="grp" class="form-control form-control-sm mono" list="acl-principals"
|
||||
placeholder="scadabridge-dcl"
|
||||
@bind="_form.Principal" @bind:after="RefreshPrincipalAsync" />
|
||||
<datalist id="acl-principals">
|
||||
@foreach (var p in _knownPrincipals)
|
||||
{
|
||||
<option value="@p"></option>
|
||||
}
|
||||
</datalist>
|
||||
<div class="form-text">
|
||||
Free text, no picker — a typo saves a grant that resolves to nothing.
|
||||
<span class="mono">Cluster</span> → leave blank ·
|
||||
<span class="mono">Namespace</span> → <span class="mono">raw</span> or
|
||||
<span class="mono">uns</span> ·
|
||||
<span class="mono">UnsArea</span> → a UnsAreaId ·
|
||||
<span class="mono">UnsLine</span> → a UnsLineId ·
|
||||
<span class="mono">Equipment</span> → an EquipmentId ·
|
||||
<span class="mono">Tag</span> → a TagId (a raw tag; the grant covers the raw
|
||||
node and every UNS projection of it) ·
|
||||
<span class="mono">RawFolder</span> → a RawFolderId ·
|
||||
<span class="mono">RawDriver</span> → a DriverInstanceId ·
|
||||
<span class="mono">RawDevice</span> → a DeviceId ·
|
||||
<span class="mono">RawTagGroup</span> → a TagGroupId ·
|
||||
<span class="mono">FolderSegment</span> → retired v2 kind, resolves nothing.
|
||||
@if (string.IsNullOrWhiteSpace(_form.Principal))
|
||||
{
|
||||
<span>
|
||||
An API key's principal, or the reserved
|
||||
<span class="mono">@OpcUaPrincipals.Anonymous</span> principal that an
|
||||
anonymous session authenticates as. Mint keys on
|
||||
<a href="/apikeys">/apikeys</a>.
|
||||
</span>
|
||||
}
|
||||
else if (_principal.IsAnonymous)
|
||||
{
|
||||
<span class="text-success">
|
||||
✓ The reserved anonymous principal — every session admitted without a
|
||||
key authenticates as this.
|
||||
</span>
|
||||
}
|
||||
else if (_principal.EnabledKeys > 0)
|
||||
{
|
||||
<span class="text-success">
|
||||
✓ @_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.
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-warning">
|
||||
⚠ 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.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Scope</label>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="chip chip-idle">@_form.ScopeKind</span>
|
||||
<span class="mono small">@(string.IsNullOrEmpty(_form.ScopeId) ? "(whole cluster)" : _form.ScopeId)</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="OpenPicker">
|
||||
Choose scope…
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text @(_scope.Ok ? "text-success" : "text-danger")">
|
||||
@(_scope.Ok ? "✓ " : "✗ ")@_scope.Message
|
||||
</div>
|
||||
<div class="form-text">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Permissions</label>
|
||||
<div>
|
||||
@@ -162,6 +175,9 @@ else
|
||||
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
<AclScopePickerModal Visible="_pickerVisible" ClusterId="@ClusterId"
|
||||
OnPicked="ScopePicked" OnCancel="() => _pickerVisible = false" />
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -183,6 +199,11 @@ else
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
private bool _pickerVisible;
|
||||
|
||||
private IReadOnlyList<string> _knownPrincipals = Array.Empty<string>();
|
||||
private PrincipalUsage _principal;
|
||||
private AclScopeCheck _scope;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -205,14 +226,46 @@ else
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_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)
|
||||
{
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Acls
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject IAclScopeService Scopes
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">ACLs · <span class="mono">@ClusterId</span></h4>
|
||||
@@ -19,24 +21,12 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>What these grants do depends on
|
||||
<span class="mono">Security:Acl:Enabled</span> on the node serving the data.</strong>
|
||||
That is per-node configuration, so this page cannot tell you which mode a given node is in —
|
||||
check the node's own settings.
|
||||
<br />
|
||||
<strong>With it off (the default):</strong> only <span class="mono">Cluster</span>-scoped grants
|
||||
do anything, and they confer a <em>server-wide</em> role on their principal —
|
||||
<span class="mono">WriteOperate</span> → writing any tag value;
|
||||
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
|
||||
(acknowledge / confirm / shelve). Narrower grants
|
||||
(<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
|
||||
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
|
||||
<span class="mono">Tag</span>, <span class="mono">Raw*</span>) are saved and shipped in every
|
||||
deployment artifact but confer <strong>nothing</strong>. That under-granting is deliberate:
|
||||
honouring such a grant's flags while its <em>scope</em> cannot be honoured would turn
|
||||
"WriteOperate on one equipment" into server-wide write authority.
|
||||
<br />
|
||||
<strong>With it on:</strong> every scope kind is honoured, and the scoped decision
|
||||
<em>replaces</em> the server-wide role rather than adding to it — so a narrow grant works, and a
|
||||
Cluster-scoped one no longer confers authority beyond that cluster.
|
||||
@@ -47,6 +37,15 @@ else
|
||||
<span class="mono">AlarmConfirm</span> / <span class="mono">AlarmShelve</span>. A principal with
|
||||
no grant at all can connect and see nothing.
|
||||
<br />
|
||||
<strong>With it off (the default):</strong> only <span class="mono">Cluster</span>-scoped grants
|
||||
do anything, and they confer a <em>server-wide</em> role on their principal —
|
||||
<span class="mono">WriteOperate</span> → writing any tag value;
|
||||
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
|
||||
(acknowledge / confirm / shelve). Narrower grants are saved and shipped in every deployment
|
||||
artifact but confer <strong>nothing</strong>. That under-granting is deliberate: honouring such
|
||||
a grant's flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on one
|
||||
equipment" into server-wide write authority.
|
||||
<br />
|
||||
A node that merely <em>leads to</em> something you may browse stays browsable, or a narrow grant
|
||||
could never be navigated to — so granting <span class="mono">Browse</span> deep in the tree also
|
||||
reveals the names of the folders above it. Revoking a grant stops live subscriptions on the next
|
||||
@@ -64,13 +63,23 @@ else
|
||||
(the cluster, a namespace, an area, an equipment, a raw device, a tag). A principal is an API
|
||||
key's principal, or the reserved <span class="mono">anonymous</span> principal that an
|
||||
anonymous session authenticates as.
|
||||
<br />
|
||||
The <strong>Scope target</strong> column resolves each row against the current configuration. A
|
||||
row marked <span class="chip chip-alert">unresolved</span> points at nothing — it is shipped in
|
||||
every deployment artifact but authorizes nobody, and the deploy gate rejects it. Open it and
|
||||
re-pick the scope.
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@_rows.Count ACL row@(_rows.Count == 1 ? "" : "s")</div>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No ACL rows for this cluster — no principal holds <span class="mono">WriteOperate</span> or <span class="mono">AlarmAck</span> here, so writes and alarm acknowledges are refused. Reads, browses, subscriptions and history reads are unaffected.</div>
|
||||
<div style="padding:1rem" class="text-muted">
|
||||
No ACL rows for this cluster. With <span class="mono">Security:Acl:Enabled</span> off, that
|
||||
means writes and alarm acknowledges are refused while reads, browses, subscriptions and
|
||||
history reads stay open to any admitted session. With it <strong>on</strong>, it means
|
||||
every data-plane operation is refused here — a client can connect and see nothing.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -92,9 +101,29 @@ else
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono small">@a.NodeAclId</span></td>
|
||||
<td><span class="mono">@a.Principal</span></td>
|
||||
<td>
|
||||
<span class="mono">@a.Principal</span>
|
||||
@if (_principals.TryGetValue(a.Principal, out var use) && !use.ReachesSomething)
|
||||
{
|
||||
<span class="chip chip-warn ms-1" title="No active API key authenticates as this principal, so the grant currently authorizes nobody.">no key</span>
|
||||
}
|
||||
</td>
|
||||
<td>@a.ScopeKind</td>
|
||||
<td><span class="mono small">@(a.ScopeId ?? "—")</span></td>
|
||||
<td>
|
||||
<span class="mono small">@(a.ScopeId ?? "—")</span>
|
||||
@if (_scopes.TryGetValue(a.NodeAclId, out var check))
|
||||
{
|
||||
@if (check.Ok)
|
||||
{
|
||||
<div class="text-muted small">@check.Message</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div><span class="chip chip-alert" title="@check.Message">unresolved</span></div>
|
||||
<div class="text-danger small">@check.Message</div>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@foreach (var perm in PermissionChips(a.PermissionFlags))
|
||||
{
|
||||
@@ -115,6 +144,8 @@ else
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
private List<NodeAcl>? _rows;
|
||||
private Dictionary<string, AclScopeCheck> _scopes = new(StringComparer.Ordinal);
|
||||
private Dictionary<string, PrincipalUsage> _principals = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -123,6 +154,19 @@ else
|
||||
.Where(a => a.ClusterId == ClusterId)
|
||||
.OrderBy(a => a.NodeAclId)
|
||||
.ToListAsync();
|
||||
|
||||
// Resolve every row's scope and principal so an unresolved grant is visible from the list rather
|
||||
// than only at deploy time. One indexed lookup each; the distinct-principal grouping keeps the
|
||||
// key lookups down to the number of distinct principals, not the number of rows.
|
||||
foreach (var row in _rows)
|
||||
{
|
||||
_scopes[row.NodeAclId] = await Scopes.CheckScopeAsync(ClusterId, row.ScopeKind, row.ScopeId);
|
||||
}
|
||||
|
||||
foreach (var principal in _rows.Select(r => r.Principal).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_principals[principal] = await Scopes.CheckPrincipalAsync(principal);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> PermissionChips(ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodePermissions flags)
|
||||
|
||||
Reference in New Issue
Block a user