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:
+4
-1
@@ -44,8 +44,11 @@ public class PageAuthorizationGuardTests
|
||||
[typeof(Scripts)] = AdminUiPolicies.ConfigEditor,
|
||||
[typeof(ScriptEdit)] = AdminUiPolicies.ConfigEditor,
|
||||
|
||||
// ── FleetAdmin (1) ──
|
||||
// ── FleetAdmin (2) ──
|
||||
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
|
||||
// Minting a key hands out a data-plane credential, and the secret is shown once at creation —
|
||||
// the same authority level as editing role grants.
|
||||
[typeof(ApiKeys)] = AdminUiPolicies.FleetAdmin,
|
||||
|
||||
// ── Secrets (1): host wrapper for the ZB.MOM.WW.Secrets.Ui RCL page (lmxopcua#483) —
|
||||
// carries the RCL page's own policy, registered additively by AddAdminUI via
|
||||
|
||||
@@ -15,6 +15,12 @@ public sealed class ArtifactApiKeyStoreTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// No test here exercises a narrower-than-Cluster scope, so the resolved scope paths are empty.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
||||
|
||||
[Fact]
|
||||
public async Task A_node_that_has_not_been_deployed_to_authenticates_nothing()
|
||||
{
|
||||
@@ -29,7 +35,7 @@ public sealed class ArtifactApiKeyStoreTests
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply("cluster-a", "rev-1",
|
||||
[Key("abc", "scadabridge-dcl")],
|
||||
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
|
||||
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], NoScopePaths);
|
||||
|
||||
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
||||
|
||||
@@ -41,16 +47,18 @@ public sealed class ArtifactApiKeyStoreTests
|
||||
[Fact]
|
||||
public async Task Only_cluster_scoped_grants_are_returned()
|
||||
{
|
||||
// Matches ConfigDbApiKeyStore, which filters the same way in SQL. Narrower scopes are not yet
|
||||
// enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately under-grants rather than
|
||||
// promoting a scoped grant's flags to server-wide authority.
|
||||
// Narrower scopes are not yet enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately
|
||||
// under-grants rather than promoting a scoped grant's flags to server-wide authority. Scope kind
|
||||
// is the only narrowing this store performs — cluster confinement already happened in
|
||||
// ParseDataPlaneAuthority, which is where it differs from ConfigDbApiKeyStore (that one filters
|
||||
// on cluster itself; see ConfigDbApiKeyStoreTests).
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply("cluster-a", "rev-1",
|
||||
[Key("abc", "p")],
|
||||
[
|
||||
Acl("acl-cluster", "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
|
||||
Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
|
||||
]);
|
||||
], NoScopePaths);
|
||||
|
||||
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
||||
|
||||
@@ -65,12 +73,12 @@ public sealed class ArtifactApiKeyStoreTests
|
||||
var state = new DataPlaneAuthorityState();
|
||||
var sut = new ArtifactApiKeyStore(state);
|
||||
|
||||
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
|
||||
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], [], NoScopePaths);
|
||||
(await sut.FindAsync("abc", Now, default)).ShouldNotBeNull();
|
||||
|
||||
var revoked = Key("abc", "p");
|
||||
revoked.Enabled = false;
|
||||
state.Apply("cluster-a", "rev-2", [revoked], []);
|
||||
state.Apply("cluster-a", "rev-2", [revoked], [], NoScopePaths);
|
||||
|
||||
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
|
||||
}
|
||||
@@ -81,7 +89,8 @@ public sealed class ArtifactApiKeyStoreTests
|
||||
// The anonymous principal has grants but obviously no credential, so FindGrantsAsync is its path.
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply("cluster-a", "rev-1", [],
|
||||
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)]);
|
||||
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)],
|
||||
NoScopePaths);
|
||||
|
||||
var grants = await new ArtifactApiKeyStore(state).FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
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;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// The key source for a node WITH a ConfigDb (#520 stage 2) — an admin-role node, including a fused
|
||||
/// admin+driver one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why cluster confinement is tested here and not on <c>ArtifactApiKeyStore</c>.</b> The two
|
||||
/// stores read different things. The artifact store's snapshot was already narrowed to the node's
|
||||
/// own cluster by <c>DeploymentArtifact.ParseDataPlaneAuthority</c>
|
||||
/// (<c>ClusterFilterMode.ScopeTo</c>), so it cannot see another cluster's rows at all. This store
|
||||
/// queries the fleet-wide ConfigDb, so the confinement is its own job — and until it was added, a
|
||||
/// Cluster-scoped grant authored in ANY cluster conferred an interim server-wide role on a fused
|
||||
/// central node.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The in-memory EF provider is the convention in the ControlPlane/Runtime test projects; nothing
|
||||
/// here touches the central SQL Server. Note it compares strings with LINQ-to-Objects ordinal
|
||||
/// semantics, NOT SQL Server's case-insensitive collation — which is exactly why the ClusterId
|
||||
/// match is done explicitly with <see cref="StringComparison.OrdinalIgnoreCase"/> in the store
|
||||
/// rather than left to the provider. <see cref="A_ClusterId_matches_case_insensitively"/> pins it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ConfigDbApiKeyStoreTests
|
||||
{
|
||||
private const string ThisCluster = "SITE-A";
|
||||
private const string OtherCluster = "SITE-B";
|
||||
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// THE REGRESSION TEST. A Cluster-scoped grant authored in another cluster must not reach this
|
||||
/// node — it would collapse into a server-wide <c>WriteOperate</c> role via
|
||||
/// <c>ApiKeyInterimRoleMapper</c>, i.e. write authority granted in SITE-B taking effect in SITE-A.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_cluster_scoped_grant_from_another_cluster_confers_nothing()
|
||||
{
|
||||
var factory = NewInMemoryDbFactory();
|
||||
Seed(factory,
|
||||
keys: [Key("abc", "scadabridge-dcl")],
|
||||
acls: [Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
|
||||
|
||||
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
|
||||
|
||||
// The KEY still resolves — keys are fleet-wide credentials (ApiKey carries no ClusterId); it is
|
||||
// the grants that are cluster-bound. Asserting a null lookup here would pass for the wrong reason.
|
||||
found.ShouldNotBeNull();
|
||||
found!.Grants.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The positive control for the test above: without it, a "fix" that returned no grants at all
|
||||
/// would pass.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_cluster_scoped_grant_in_this_cluster_still_confers_its_permissions()
|
||||
{
|
||||
var factory = NewInMemoryDbFactory();
|
||||
Seed(factory,
|
||||
keys: [Key("abc", "scadabridge-dcl")],
|
||||
acls:
|
||||
[
|
||||
Acl("acl-mine", ThisCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
||||
Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
||||
]);
|
||||
|
||||
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
|
||||
|
||||
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A legacy / single-cluster node carries no <c>cluster-{ClusterId}</c> role, so its ClusterId is
|
||||
/// null and NO filter applies — mirroring the artifact path's <c>ClusterFilterMode.None</c>.
|
||||
/// Failing closed here would silently strip every grant on an existing deployment.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_node_with_no_cluster_scoped_role_sees_every_cluster_scoped_grant()
|
||||
{
|
||||
var factory = NewInMemoryDbFactory();
|
||||
Seed(factory,
|
||||
keys: [Key("abc", "p")],
|
||||
acls:
|
||||
[
|
||||
Acl("acl-a", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
|
||||
Acl("acl-b", OtherCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
||||
]);
|
||||
|
||||
var found = await new ConfigDbApiKeyStore(factory, clusterId: null).FindAsync("abc", Now, default);
|
||||
|
||||
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-a", "acl-b"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The node's ClusterId comes from its role suffix, and that arrives in either the operator's
|
||||
/// spelling (<c>Cluster:Roles</c> binds verbatim) or lower-cased (<c>OTOPCUA_ROLES</c> goes through
|
||||
/// <c>RoleParser.Parse</c>, which calls <c>ToLowerInvariant</c>). The ACL row's ClusterId is
|
||||
/// authored independently. A case-sensitive match would therefore strip every grant on a node
|
||||
/// configured the second way — so the match is OrdinalIgnoreCase, the same comparison
|
||||
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> uses.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_ClusterId_matches_case_insensitively()
|
||||
{
|
||||
var factory = NewInMemoryDbFactory();
|
||||
Seed(factory,
|
||||
keys: [Key("abc", "p")],
|
||||
acls: [Acl("acl-mine", "SITE-A", "p", NodeAclScopeKind.Cluster, NodePermissions.Read)]);
|
||||
|
||||
var found = await new ConfigDbApiKeyStore(factory, "site-a").FindAsync("abc", Now, default);
|
||||
|
||||
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>FindGrantsAsync</c> is the anonymous principal's path — no credential, so it never goes
|
||||
/// through <c>FindAsync</c>. It must be confined to the node's cluster too.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Anonymous_grants_are_cluster_confined_on_their_own_path()
|
||||
{
|
||||
var factory = NewInMemoryDbFactory();
|
||||
Seed(factory,
|
||||
keys: [],
|
||||
acls:
|
||||
[
|
||||
Acl("acl-anon-mine", ThisCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read),
|
||||
Acl("acl-anon-other", OtherCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
||||
]);
|
||||
|
||||
var grants = await new ConfigDbApiKeyStore(factory, ThisCluster)
|
||||
.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
||||
|
||||
grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-anon-mine");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Narrower-than-Cluster scopes are still excluded (stage 5 widens this) — the cluster filter is
|
||||
/// an ADDITIONAL narrowing, not a replacement for the scope-kind one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_narrower_scope_in_this_cluster_is_still_excluded()
|
||||
{
|
||||
var factory = NewInMemoryDbFactory();
|
||||
Seed(factory,
|
||||
keys: [Key("abc", "p")],
|
||||
acls:
|
||||
[
|
||||
Acl("acl-cluster", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
|
||||
Acl("acl-equipment", ThisCluster, "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
|
||||
]);
|
||||
|
||||
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
|
||||
|
||||
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-cluster");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ClusterId derivation itself: the same one <c>ClusterRoleInfo</c> performs for
|
||||
/// <c>IClusterRoleInfo.ClusterId</c>, off the node's OWN configured roles.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveClusterId_reads_the_nodes_own_cluster_scoped_role()
|
||||
{
|
||||
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["driver", "cluster-SITE-A"] })
|
||||
.ShouldBe("SITE-A");
|
||||
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["admin", "driver"] })
|
||||
.ShouldBeNull();
|
||||
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions())
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static void Seed(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> factory,
|
||||
IReadOnlyList<ApiKey> keys,
|
||||
IReadOnlyList<NodeAcl> acls)
|
||||
{
|
||||
using var db = factory.CreateDbContext();
|
||||
db.ApiKeys.AddRange(keys);
|
||||
db.NodeAcls.AddRange(acls);
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static ApiKey Key(string id, string principal) => new()
|
||||
{
|
||||
ApiKeyRowId = Guid.NewGuid(),
|
||||
ApiKeyId = id,
|
||||
Name = id,
|
||||
SecretHash = "hash",
|
||||
Principal = principal,
|
||||
Enabled = true,
|
||||
CreatedUtc = Now.AddDays(-1),
|
||||
};
|
||||
|
||||
private static NodeAcl Acl(
|
||||
string id, string clusterId, string principal, NodeAclScopeKind scope, NodePermissions flags) => new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = id,
|
||||
ClusterId = clusterId,
|
||||
Principal = principal,
|
||||
ScopeKind = scope,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static IDbContextFactory<OtOpcUaConfigDbContext> NewInMemoryDbFactory() =>
|
||||
new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"));
|
||||
|
||||
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
/// <summary>Creates a context over this test's own in-memory database.</summary>
|
||||
/// <returns>A new <see cref="OtOpcUaConfigDbContext"/>.</returns>
|
||||
public OtOpcUaConfigDbContext CreateDbContext() =>
|
||||
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(dbName).Options);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<!-- ConfigDbApiKeyStoreTests drives the store against a real EF query, the same in-memory
|
||||
provider convention the ControlPlane/Runtime test projects use. No central SQL Server. -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage of the ACL scope path as it actually runs on a node (#520 stage 3):
|
||||
/// artifact bytes → <see cref="DeploymentArtifact.ParseDataPlaneAuthority"/> → scope paths →
|
||||
/// <see cref="PermissionTrieBuilder.Build"/> → <see cref="PermissionTrie.CollectMatches"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the coverage the design doc called out as missing, and it was missing in a specific
|
||||
/// way worth remembering: the pre-stage-3 tests all hand-built the <c>scopePaths</c> dictionary in
|
||||
/// the test body with exactly the segments the walker expected, so the walker was pinned against
|
||||
/// the test's idea of the format rather than against any producer. Nothing proved a real artifact
|
||||
/// could yield a matching grant, and in fact nothing could have — there was no producer at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the artifact JSON here is deliberately hand-written rather than composed: it is standing in
|
||||
/// for the wire format, and the parser must agree with <em>it</em>. The composer→parser agreement
|
||||
/// is pinned separately, in <c>ControlPlane.Tests/DataPlaneAuthorityArtifactTests</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DeploymentArtifactScopePathTests
|
||||
{
|
||||
private const string NodeId = "node-a:4840";
|
||||
|
||||
// Plant/Line1 → ModbusDrv → dev1 → Fast → Speed, projected into equipment West/L1/Mixer as "MixerSpeed".
|
||||
private const string Artifact = """
|
||||
{
|
||||
"RawFolders": [
|
||||
{"RawFolderId":"RF-plant","ClusterId":"c1","ParentRawFolderId":null,"Name":"Plant"},
|
||||
{"RawFolderId":"RF-line1","ClusterId":"c1","ParentRawFolderId":"RF-plant","Name":"Line1"}
|
||||
],
|
||||
"DriverInstances": [
|
||||
{"DriverInstanceId":"DI-modbus","ClusterId":"c1","RawFolderId":"RF-line1","Name":"ModbusDrv"}
|
||||
],
|
||||
"Devices": [{"DeviceId":"DV-1","DriverInstanceId":"DI-modbus","Name":"dev1"}],
|
||||
"TagGroups": [{"TagGroupId":"TG-fast","DeviceId":"DV-1","ParentTagGroupId":null,"Name":"Fast"}],
|
||||
"Tags": [{"TagId":"TAG-speed","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Speed"}],
|
||||
"UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}],
|
||||
"UnsLines": [{"UnsLineId":"UL-1","UnsAreaId":"UA-w","Name":"L1"}],
|
||||
"Equipment": [{"EquipmentId":"EQ-mixer","UnsLineId":"UL-1","Name":"Mixer"}],
|
||||
"UnsTagReferences": [
|
||||
{"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed","DisplayNameOverride":"MixerSpeed"}
|
||||
],
|
||||
"NodeAcls": [
|
||||
{"NodeAclId":"acl-dev","ClusterId":"c1","Principal":"p","ScopeKind":9,"ScopeId":"DV-1","PermissionFlags":2},
|
||||
{"NodeAclId":"acl-eq","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16},
|
||||
{"NodeAclId":"acl-dangling","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-gone","PermissionFlags":16}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static (PermissionTrie Trie, List<PermissionTrieBuildDiagnostic> Diagnostics) BuildFromArtifact()
|
||||
{
|
||||
var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId);
|
||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, projection.Grants, projection.ScopePaths, diagnostics.Add);
|
||||
return (trie, diagnostics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_raw_device_grant_from_the_artifact_matches_a_tag_beneath_that_device()
|
||||
{
|
||||
var (trie, _) = BuildFromArtifact();
|
||||
|
||||
var matches = trie.CollectMatches(
|
||||
new NodeScope
|
||||
{
|
||||
ClusterId = "c1",
|
||||
Kind = NodeHierarchyKind.Raw,
|
||||
Segments = ["Plant", "Line1", "ModbusDrv", "dev1", "Fast", "Speed"],
|
||||
},
|
||||
["p"]);
|
||||
|
||||
var match = matches.ShouldHaveSingleItem();
|
||||
match.PermissionFlags.ShouldBe(NodePermissions.Read);
|
||||
// Provenance is the authored kind, not the depth the grant happens to sit at.
|
||||
match.Scope.ShouldBe(NodeAclScopeKind.RawDevice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tag_under_a_DIFFERENT_device_does_not_match_that_grant()
|
||||
{
|
||||
// The negative control. Without it the test above passes for any trie that returns every grant it
|
||||
// holds regardless of path — which is exactly what a broken walk would do.
|
||||
var (trie, _) = BuildFromArtifact();
|
||||
|
||||
trie.CollectMatches(
|
||||
new NodeScope
|
||||
{
|
||||
ClusterId = "c1",
|
||||
Kind = NodeHierarchyKind.Raw,
|
||||
Segments = ["Plant", "Line1", "ModbusDrv", "otherdev", "Fast", "Speed"],
|
||||
},
|
||||
["p"]).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_same_grant_set_matches_through_the_UNS_realm_too()
|
||||
{
|
||||
// The equipment grant is authored once and must be reachable at the UNS NodeId, which is a
|
||||
// completely different path from the raw one for the same underlying value.
|
||||
var (trie, _) = BuildFromArtifact();
|
||||
|
||||
var matches = trie.CollectMatches(
|
||||
new NodeScope
|
||||
{
|
||||
ClusterId = "c1",
|
||||
Kind = NodeHierarchyKind.Uns,
|
||||
Segments = ["West", "L1", "Mixer", "MixerSpeed"],
|
||||
},
|
||||
["p"]);
|
||||
|
||||
matches.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_raw_scope_does_not_pick_up_a_UNS_grant_at_the_same_depth()
|
||||
{
|
||||
// Realm isolation. "West/L1/Mixer" and a three-segment raw path are the same depth, so without the
|
||||
// realm gate at level 1 a UNS grant would match a raw node whose folder/driver/device happened to
|
||||
// be named the same. The two trees are authored independently, so that collision is not exotic.
|
||||
var (trie, _) = BuildFromArtifact();
|
||||
|
||||
trie.CollectMatches(
|
||||
new NodeScope
|
||||
{
|
||||
ClusterId = "c1",
|
||||
Kind = NodeHierarchyKind.Raw,
|
||||
Segments = ["West", "L1", "Mixer", "MixerSpeed"],
|
||||
},
|
||||
["p"]).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_dangling_scope_is_dropped_with_a_diagnostic_rather_than_planted()
|
||||
{
|
||||
var (_, diagnostics) = BuildFromArtifact();
|
||||
|
||||
var diagnostic = diagnostics.ShouldHaveSingleItem();
|
||||
diagnostic.NodeAclId.ShouldBe("acl-dangling");
|
||||
diagnostic.Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Case_drift_in_a_path_segment_grants_nothing()
|
||||
{
|
||||
// Raw sibling names are unique under an ordinal comparer, so "Speed" and "speed" can both exist
|
||||
// under one device. The trie compared OrdinalIgnoreCase before stage 3, which would have let a
|
||||
// grant on either match both. This goes red if the comparer is reverted.
|
||||
var (trie, _) = BuildFromArtifact();
|
||||
|
||||
trie.CollectMatches(
|
||||
new NodeScope
|
||||
{
|
||||
ClusterId = "c1",
|
||||
Kind = NodeHierarchyKind.Raw,
|
||||
Segments = ["plant", "Line1", "ModbusDrv", "dev1", "Fast", "Speed"],
|
||||
},
|
||||
["p"]).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_artifact_with_no_topology_resolves_no_scopes()
|
||||
{
|
||||
var projection = DeploymentArtifact.ParseDataPlaneAuthority(
|
||||
Encoding.UTF8.GetBytes("""
|
||||
{"NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-x","PermissionFlags":1}]}
|
||||
"""),
|
||||
NodeId);
|
||||
|
||||
projection.Grants.ShouldHaveSingleItem();
|
||||
projection.ScopePaths.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user