d542ab5b7c
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
73 lines
4.0 KiB
C#
73 lines
4.0 KiB
C#
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
|
|
|
|
/// <summary>
|
|
/// Backs the ACL authoring surface (#520 stage 7): the scope picker's two trees, and the
|
|
/// authoring-time checks that stop a grant being saved against a principal or a scope that names
|
|
/// nothing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>A service rather than logic in the Razor pages, because this repo has no bUnit</b> — anything
|
|
/// only a page can reach is untestable, and the whole point of the stage is that authoring stops
|
|
/// producing grants that silently authorize nobody. The same rationale
|
|
/// <c>IApiKeyAdminService</c> carries.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The picker chooses the kind and the id together.</b> Before this, <c>ScopeKind</c> was a
|
|
/// dropdown and <c>ScopeId</c> a free-text box, so the two could disagree — a
|
|
/// <see cref="NodeAclScopeKind.RawDevice"/> row pointing at a TagId is well-formed, saves, deploys,
|
|
/// and resolves to nothing. Selecting a tree node produces both, so that pairing cannot be made by
|
|
/// hand. <see cref="CheckScopeAsync"/> is what catches the rows that already exist, or that were
|
|
/// typed in before the picker was reached.
|
|
/// </para>
|
|
/// </remarks>
|
|
public interface IAclScopeService
|
|
{
|
|
/// <summary>
|
|
/// Loads the raw and UNS scope trees for one cluster. Both are rooted at that cluster, so a scope in
|
|
/// another cluster is unreachable through the picker rather than merely discouraged — the structural
|
|
/// equivalent of the deploy gate's <c>NodeAclScopeCrossCluster</c> rule.
|
|
/// </summary>
|
|
/// <param name="clusterId">The cluster the grant belongs to.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The two trees; either may be empty when the cluster has nothing of that kind yet.</returns>
|
|
Task<AclScopeRoots> LoadRootsAsync(string clusterId, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Loads one lazy level beneath a raw-tree node. UNS nodes are loaded whole by
|
|
/// <see cref="LoadRootsAsync"/> and never reach here.
|
|
/// </summary>
|
|
/// <param name="parent">The node being expanded.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The child rows, or empty for a leaf.</returns>
|
|
Task<IReadOnlyList<AclScopeNode>> LoadChildrenAsync(AclScopeNode parent, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Resolves an authored scope against the current configuration, mirroring the deploy gate's
|
|
/// <c>NodeAclScope*</c> rules so the page rejects what the deployment would reject.
|
|
/// </summary>
|
|
/// <param name="clusterId">The cluster the grant belongs to.</param>
|
|
/// <param name="kind">The authored scope kind.</param>
|
|
/// <param name="scopeId">The authored scope id; null or blank for a cluster-wide grant.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>What the scope resolves to, or why it does not.</returns>
|
|
Task<AclScopeCheck> CheckScopeAsync(
|
|
string clusterId, NodeAclScopeKind kind, string? scopeId, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Lists the principal names worth suggesting: every principal an API key authenticates as, every
|
|
/// principal an existing grant already names, and the reserved <c>anonymous</c> principal.
|
|
/// </summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>Distinct principal names, ordered.</returns>
|
|
Task<IReadOnlyList<string>> LoadKnownPrincipalsAsync(CancellationToken ct = default);
|
|
|
|
/// <summary>Reports how many API keys currently authenticate as a principal.</summary>
|
|
/// <param name="principal">The principal name as authored.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The usage counts.</returns>
|
|
Task<PrincipalUsage> CheckPrincipalAsync(string principal, CancellationToken ct = default);
|
|
}
|