3a6b2a1daf
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
144 lines
7.5 KiB
C#
144 lines
7.5 KiB
C#
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;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
|
|
/// <summary>
|
|
/// <see cref="IApiKeyStore"/> reading the config database directly (#520, stage 2). Usable on a node
|
|
/// that has a ConfigDb — i.e. one carrying the <c>admin</c> role, including a fused admin+driver node.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>A driver-only node has no ConfigDb since per-cluster mesh Phase 4</b>, so this store cannot be
|
|
/// registered there; that node uses <see cref="ArtifactApiKeyStore"/> instead (#520 stage 4).
|
|
/// <para>
|
|
/// 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
|
|
{
|
|
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _factory;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="ConfigDbApiKeyStore"/> class.</summary>
|
|
/// <param name="factory">The config DB context factory.</param>
|
|
/// <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)
|
|
{
|
|
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
|
|
|
// Enabled + expiry are filtered in SQL rather than in memory so a disabled key never
|
|
// materialises into a process where something might later treat it as a match.
|
|
var key = await db.ApiKeys
|
|
.AsNoTracking()
|
|
.SingleOrDefaultAsync(
|
|
k => k.ApiKeyId == apiKeyId
|
|
&& k.Enabled
|
|
&& (k.ExpiresUtc == null || k.ExpiresUtc > utcNow),
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
|
|
if (key is null) return null;
|
|
|
|
return new ApiKeyLookup(key, await LoadClusterGrantsAsync(db, key.Principal, ct).ConfigureAwait(false));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
|
|
{
|
|
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
|
return await LoadClusterGrantsAsync(db, principal, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
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.
|
|
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;
|
|
}
|
|
}
|