feat(acl): variable-depth scope model + the scopePaths producer (#520 stage 3)
Makes the /raw device tree addressable by an ACL rule, and builds the
scope-path producer PermissionTrieBuilder has accepted since it was written
but which had no caller anywhere.
The plan for this stage said "add NodeHierarchyKind.Raw, whose path is the
RawPath segments Folder/Driver/Device/TagGroup/Tag". That shape is wrong.
RawFolder.ParentRawFolderId and TagGroup.ParentTagGroupId are both
self-referencing, so folders and tag groups each nest without bound and a real
RawPath is folder…/Driver/Device/group…/Tag. NodeScope was fixed-arity and
PermissionTrie.CollectMatches was five hardcoded steps, so the scope model
could not address a real raw tree at all. NodeScope is now {ClusterId, Kind,
Segments} and the walk is a segment loop. The trie STRUCTURE was already
variable-depth (NodeAclPath.Segments is a list, Children is a recursive
dictionary), so only the walker and the scope record had to change.
Four decisions worth keeping:
- The namespace level became the realm. Level 1 gated on NodeScope.NamespaceId,
but v3 has no Namespace entity — the snapshot was retired when the address
space went dual-namespace, and the identifier appears nowhere in production
outside NodeScope. So the level could only ever fail to match, which silently
made every sub-cluster grant unreachable while cluster grants kept working.
It is now a realm token derived from the scope's Kind, not carried on it, so
the walk and the producer cannot disagree about its spelling.
- Provenance comes from the granting row's own ScopeKind, recorded on TrieGrant
at build time. Depth-derived provenance could not name a raw level at all,
since the depth of "Device" varies with folder nesting.
- The trie comparer moved from OrdinalIgnoreCase to Ordinal. Raw sibling-name
uniqueness is ordinal, so Speed and speed can both exist under one device and
a grant on either would have matched both.
- The null-scopePaths "deterministic test mode" is deleted. It planted
unresolvable rows at a direct child of the trie root, a position the walker
cannot reach, so the grant looked placed and could never match — and every
test using that mode proved nothing about production placement. scopePaths is
now required, and a row that does not resolve is dropped with a diagnostic.
A scope resolves to a LIST of paths: a raw tag projected into N equipments is
reachable at N+1 NodeIds and a write through any of them lands on the same
driver reference, so a Tag-scoped grant covering only the raw node would be
bypassable through a UNS one. The lookup is keyed on (ScopeKind, ScopeId), not
ScopeId alone — ScopeId has no FK and no type discriminator, so an id collision
across entity types would hand a grant the wrong node's paths.
Also closed here, all found by review rather than asked for:
- NodeAcl rows had no deploy-time validation of any kind and were not reachable
from DraftValidator at all (DraftSnapshot carried no NodeAcls). They are now
gated for dangling ids, cross-cluster scopes, blank/contradictory ScopeId, the
retired FolderSegment kind, and undefined ordinals.
- ConfigDbApiKeyStore conferred roles from Cluster-scoped grants authored in ANY
cluster, because it filtered on scope kind with no ClusterId predicate while
ArtifactApiKeyStore read an already-cluster-filtered snapshot. On a fused
admin+driver node that was a cross-cluster over-grant. Its doc comment claimed
the two stores matched.
- The two AdminUI ACL banners still said enforcement was LDAP-group -> role
mapping, which stages 1/2 made false in both directions.
- The /apikeys page was never classified in PageAuthorizationGuardTests, so that
test has been red since the key-minting commit merged — I did not run
AdminUI.Tests before merging stage 4.
Falsified rather than assumed: 8 mutations against the trie tests, 5 against the
deploy gate (including one proving a cross-cluster row is not silently
mis-diagnosed as unresolved), a staged red->green for the over-grant regression,
and the realm gate, the comparer and the malformed-path guard each broken
individually to confirm only their own tests go red.
Node ACLs are still not ENFORCED — nothing constructs a NodeScope or calls the
evaluator yet. That is stages 5 and 6; this stage makes the model able to
express what those will enforce.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
@@ -16,6 +17,13 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
/// Reading per request is what makes revocation immediate here, which is why this store is
|
||||
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>The ConfigDb holds the WHOLE fleet's ACL rows, not just this node's cluster's.</b> That is
|
||||
/// the difference from <see cref="ArtifactApiKeyStore"/>, whose snapshot was already narrowed to
|
||||
/// the node's own cluster by <c>DeploymentArtifact.ParseDataPlaneAuthority</c>. So this store must
|
||||
/// confine grants to <see cref="ClusterId"/> itself — without that predicate a Cluster-scoped
|
||||
/// grant authored in ANY cluster in the fleet conferred an interim server-wide role here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ConfigDbApiKeyStore : IApiKeyStore
|
||||
{
|
||||
@@ -23,7 +31,57 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ConfigDbApiKeyStore"/> class.</summary>
|
||||
/// <param name="factory">The config DB context factory.</param>
|
||||
public ConfigDbApiKeyStore(IDbContextFactory<OtOpcUaConfigDbContext> factory) => _factory = factory;
|
||||
/// <param name="clusterId">
|
||||
/// This node's own ClusterId, from <see cref="ResolveClusterId"/>. <see langword="null"/> on a
|
||||
/// legacy / single-cluster node that carries no <c>cluster-{ClusterId}</c> role — see
|
||||
/// <see cref="ClusterId"/> for what that means for filtering.
|
||||
/// </param>
|
||||
public ConfigDbApiKeyStore(IDbContextFactory<OtOpcUaConfigDbContext> factory, string? clusterId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
_factory = factory;
|
||||
ClusterId = string.IsNullOrWhiteSpace(clusterId) ? null : clusterId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The node's own ClusterId, or <see langword="null"/> when it carries no cluster-scoped role.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <see langword="null"/> ClusterId applies NO cluster filter, mirroring the artifact path's
|
||||
/// <c>ClusterFilterMode.None</c> — a single-cluster artifact is applied whole. Failing closed here
|
||||
/// instead would silently strip every grant on an existing single-cluster deployment, which is a
|
||||
/// worse failure than the one being fixed and would not be visible until a client was denied.
|
||||
/// </remarks>
|
||||
public string? ClusterId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the node's ClusterId from its OWN configured roles, e.g. <c>cluster-SITE-A</c> →
|
||||
/// <c>SITE-A</c>; <see langword="null"/> when no role is cluster-scoped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the same derivation <see cref="Cluster.ClusterRoleInfo"/> performs for
|
||||
/// <c>IClusterRoleInfo.ClusterId</c>, reusing the same <see cref="RoleParser"/> primitives —
|
||||
/// first cluster-scoped role wins, as it does there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Takes <see cref="AkkaClusterOptions"/> (pure configuration), NOT
|
||||
/// <c>IClusterRoleInfo</c> resolved from the service provider.</b> <c>ClusterRoleInfo</c>
|
||||
/// depends on the <c>ActorSystem</c>; resolving it during host composition recurses into
|
||||
/// ActorSystem construction and stack-overflows the node at boot while compiling clean (see
|
||||
/// CLAUDE.md, per-cluster mesh Phase 6). The ClusterId is configuration, so read it from
|
||||
/// configuration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="options">The node's bound cluster options.</param>
|
||||
/// <returns>The node's ClusterId, or <see langword="null"/> when it carries no cluster-scoped role.</returns>
|
||||
public static string? ResolveClusterId(AkkaClusterOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var clusterRole = options.Roles.FirstOrDefault(RoleParser.IsClusterRole);
|
||||
return clusterRole is null ? null : RoleParser.ClusterIdFromRole(clusterRole);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
|
||||
@@ -53,15 +111,33 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
|
||||
return await LoadClusterGrantsAsync(db, principal, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<NodeAcl>> LoadClusterGrantsAsync(
|
||||
private async Task<IReadOnlyList<NodeAcl>> LoadClusterGrantsAsync(
|
||||
OtOpcUaConfigDbContext db, string principal, CancellationToken ct)
|
||||
{
|
||||
// Only cluster-scoped rows can confer an interim role (see ApiKeyInterimRoleMapper), so only
|
||||
// those are fetched. Stage 5 widens this to the full grant set for per-node evaluation.
|
||||
return await db.NodeAcls
|
||||
var rows = await db.NodeAcls
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Principal == principal && a.ScopeKind == NodeAclScopeKind.Cluster)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ClusterId is null) return rows;
|
||||
|
||||
// The ClusterId match is done HERE rather than as a SQL predicate, unlike the Enabled/expiry
|
||||
// filters above, because it must be case-insensitive DETERMINISTICALLY. `a.ClusterId == ...` in
|
||||
// SQL is decided by the database collation — case-insensitive on the usual SQL Server default,
|
||||
// case-SENSITIVE on a CS collation or on the in-memory provider the tests use — and the two
|
||||
// spellings genuinely differ: `Cluster:Roles` binds a role verbatim (cluster-SITE-A ⇒ SITE-A)
|
||||
// while the OTOPCUA_ROLES path goes through RoleParser.Parse, which lower-cases it (⇒ site-a).
|
||||
// A collation-dependent match would therefore strip every grant on a node configured the second
|
||||
// way. OrdinalIgnoreCase is the SAME comparison DeploymentArtifact.ParseDataPlaneAuthority uses
|
||||
// to scope the artifact's grants, so both stores confine identically.
|
||||
var mine = new List<NodeAcl>(rows.Count);
|
||||
foreach (var grant in rows)
|
||||
{
|
||||
if (string.Equals(grant.ClusterId, ClusterId, StringComparison.OrdinalIgnoreCase)) mine.Add(grant);
|
||||
}
|
||||
return mine;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user