f692f6afe4
`Security:Acl:Enabled` (default false) makes scoped `NodeAcl` grants decide data-plane authorization at the three seams that have one, and gives HistoryRead a check it never had. Read, Browse and Subscribe stay ungated — stage 6. The ACL decision REPLACES the server-wide role check rather than chaining with it. Chained, a grant would have to confer the role as well as pass the scope check, and only Cluster-scoped grants confer a role — so every narrow grant would have stayed inert and the flag would have looked inert too. The load-bearing test is an ALLOW: a session carrying no roles at all writing successfully on the strength of its grant. A deny-only test passes against a build where the ACL is never consulted, because the role gate denies too. Found en route: the principal never reached the session. The authenticator passed it as OpcUaUserAuthResult.DisplayName, but RoleCarryingUserIdentity derives DisplayName from the presented token — for a key-bearing client that is the static username `apikey`, identical fleet-wide. So the trie's matching key was dropped at the identity boundary, and the audit ClientUserId on a rejected write recorded `apikey` for every consuming system. Principal and ApiKeyId are now carried explicitly. Beyond the stage as sketched: - Revocation reaches an OPEN session. Every decision re-checks the authenticating key against the current snapshot; without it a key revoked by a deployment kept its authority until the client chose to reconnect, which for a machine-to-machine consumer can be indefinitely. - UserAuthorizationState is built per CALL, not per session. The per-session shape exists so an LDAP session can re-resolve membership that changed out of band; nothing can change out of band under API keys, so the generation stamp is the whole invalidation signal and a per-call record picks it up for free. IsStale/NeedsRefresh therefore cannot fire here and are documented as vestigial. - The trie is built inside DataPlaneAuthorityState.Apply, under the same generation as the snapshot, rather than by a second wiring at the publish actor. An uninstalled trie denies everything — indistinguishable from working enforcement. Deleting the install turns 13 of the 22 new tests red. - The deploy log reports what the BUILD dropped instead of re-deriving it. The old count agreed with the builder on one of its four drop reasons; a rule with a blank ScopeId, a retired scope kind, or a path not starting at a realm vanished with the log reading zero. Both IApiKeyStore implementations widened to the full grant set. The narrowing was redundant — ApiKeyInterimRoleMapper applies the same filter itself — and became a trap once scope was enforced. Pinned by asserting the roles are unchanged, with the two grants carrying different flags so it cannot pass by accident. Falsified by mutation: enforcement never engaging, the write seam ignoring the ACL, the realm dropped from the scope, the revocation check removed, and the trie never installed each turn the intended tests red and no others. Full solution suite green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
282 lines
15 KiB
Plaintext
282 lines
15 KiB
Plaintext
@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.Configuration
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
@inject NavigationManager Nav
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h4 class="mb-0">@(IsNew ? "New ACL grant" : "Edit ACL grant") · <span class="mono">@ClusterId</span></h4>
|
|
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
|
</div>
|
|
|
|
<ClusterNav ClusterId="@ClusterId" ActiveTab="acls" />
|
|
|
|
@if (!_loaded)
|
|
{
|
|
<p>Loading…</p>
|
|
}
|
|
else if (!IsNew && _existing is null)
|
|
{
|
|
<section class="panel notice rise" style="animation-delay:.02s">ACL <span class="mono">@NodeAclId</span> not found.</section>
|
|
}
|
|
else
|
|
{
|
|
<div class="alert alert-warning" 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. Writes, alarm commands and HistoryRead are gated; the
|
|
alarm methods separate into <span class="mono">AlarmAcknowledge</span> /
|
|
<span class="mono">AlarmConfirm</span> / <span class="mono">AlarmShelve</span>.
|
|
<br />
|
|
<strong>Read, Browse and Subscribe are not gated in either mode</strong>, for any admitted
|
|
session, including anonymous.
|
|
<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>.
|
|
</div>
|
|
|
|
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
|
|
<DataAnnotationsValidator />
|
|
<section class="panel rise" style="animation-delay:.02s">
|
|
<div class="panel-head">Grant</div>
|
|
<div style="padding:1rem">
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label" for="aid">NodeAclId</label>
|
|
<InputText id="aid" @bind-Value="_form.NodeAclId" disabled="@(!IsNew)" class="form-control form-control-sm mono" />
|
|
</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" />
|
|
<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.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Permissions</label>
|
|
<div>
|
|
@foreach (var bit in PermissionBits)
|
|
{
|
|
<div class="form-check form-check-inline">
|
|
<input type="checkbox" class="form-check-input"
|
|
id="perm-@bit"
|
|
checked="@_form.HasPerm(bit)"
|
|
@onchange="e => _form.SetPerm(bit, (bool)e.Value!)" />
|
|
<label class="form-check-label" for="perm-@bit">@bit</label>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="form-text mt-2">
|
|
Bundles:
|
|
<button type="button" class="btn btn-sm btn-link p-0 ms-1" @onclick="() => _form.PermissionFlags = NodePermissions.ReadOnly">ReadOnly</button>
|
|
·
|
|
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Operator">Operator</button>
|
|
·
|
|
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Engineer">Engineer</button>
|
|
·
|
|
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Admin">Admin</button>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Notes</label>
|
|
<InputTextArea @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div> }
|
|
|
|
<div class="mt-3 d-flex gap-2">
|
|
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button>
|
|
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary">Cancel</a>
|
|
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
|
|
</div>
|
|
</EditForm>
|
|
}
|
|
|
|
@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;
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
_loaded = true;
|
|
}
|
|
|
|
private async Task SubmitAsync()
|
|
{
|
|
_busy = true; _error = null;
|
|
try
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
}
|