fix(acl): close the #808 review findings — reference variables, owner folder chain, NUL keys, unknown ids (#808)

Review of af5c9744:
1. UNS reference variables (s=Area/Line/Equip/Eff) of a moved or renamed
   equipment stayed readable/writable under the OLD names' grants: they now
   resolve as ReferenceVariable and are authorized at their split path only
   while it equals their live equipment folder's placement (live chain ==
   generation chain) + one name; otherwise denied (Read and Write).
2. TryPlaceScriptedCondition now also requires the owner folder's live chain
   to match the generation (AuthorizeScriptedCondition gains ownerFolderChain),
   so a moved equipment's alarm/children/commands follow its folder.
3. Identifiers containing NUL are denied before the decision cache, keeping
   client ids disjoint from the NUL-prefixed resolved-scope keys (a client
   could otherwise poison a real folder's cached verdict for its key shard).
4. An id no live node holds is Unknown: placed at [\0unknown] so only
   Cluster / Namespace-uns grants decide it (a removed opaque id can no longer
   split onto a same-named narrow grant; narrow grantees get no NodeDeleted for
   removed UNS folders/vtags/conditions — documented); 4-segment
   reference-shaped ids keep their split.
5. A wire test drives placements from DeploymentArtifact.ParseDataPlaneAuthority.

Baseline updates: #797 owner tests (unit + wire) and Sdk2RepeatedAlarmAuthority
now install folder scopes; Sdk2HistoryAuthority's UNS alias used the RAW path
as its UNS id (never minted in production) and now carries EqA/HistoryAlias.
Each new guard was falsified (reverting it fails its test).
This commit is contained in:
Joseph Doherty
2026-11-10 23:44:11 -05:00
parent af5c9744b7
commit e39a125069
11 changed files with 557 additions and 64 deletions
@@ -234,7 +234,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
// The generation no longer places the owner (equipment deleted from the artifact while the
// node is still served): the grant's own scope stays resolvable, the owner does not.
state.Apply(Cluster, "rev-2", [key], [grant], Resolver().Resolve([grant]),
new Dictionary<string, NodeAclPath>(), Deployed());
new Dictionary<string, NodeAclPath>(), Deployed(), FolderScopes());
break;
}
@@ -595,7 +595,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
{
[new AclScopeRef(NodeAclScopeKind.RawDevice, "dev1")] = [new NodeAclPath([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])],
};
state.Apply(Cluster, "rev-raw", [Key()], [rawDevice], scopes, OwnerPaths(), Deployed());
state.Apply(Cluster, "rev-raw", [Key()], [rawDevice], scopes, OwnerPaths(), Deployed(), FolderScopes());
condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("raw grant")).ShouldBe(ServiceResult.Good);
condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("raw note")).ShouldBe(ServiceResult.Good);
@@ -684,6 +684,23 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
};
}
/// <summary>
/// Gitea #808 review: the scripted-condition placement also requires the owner's LIVE folder ancestry to match
/// the generation's, so every install here carries the topology's folder scopes (same resolver as the owners).
/// </summary>
private static Dictionary<string, UnsFolderScope> FolderScopes()
{
var resolver = Resolver();
NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem();
return new Dictionary<string, UnsFolderScope>
{
[AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]),
[LineId] = new(One(NodeAclScopeKind.UnsLine, LineId), [AreaId, LineId]),
[Pump] = new(One(NodeAclScopeKind.Equipment, Pump), [AreaId, LineId, Pump]),
[Fan] = new(One(NodeAclScopeKind.Equipment, Fan), [AreaId, LineId, Fan]),
};
}
private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire(
OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, params NodeAcl[] grants) =>
Wire(nm, clock, keys, enabled: true, Areas(), grants);
@@ -702,7 +719,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
IReadOnlyDictionary<string, string> areas, NodeAcl[] grants)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", keys, grants, Resolver(areas).Resolve(grants), OwnerPaths(), Deployed());
state.Apply(Cluster, "rev-1", keys, grants, Resolver(areas).Resolve(grants), OwnerPaths(), Deployed(), FolderScopes());
var authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, clock);
nm.DataPlaneAuthority = state;
@@ -717,7 +734,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
private static void Reapply(
DataPlaneAuthorityState state, string revision, IReadOnlyDictionary<string, string> deployedOwners,
ApiKey[] keys, params NodeAcl[] grants) =>
state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners);
state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners, FolderScopes());
/// <summary>
/// The generation's deployed scripted-alarm owners (the artifact's <c>ScriptedAlarms</c> rows);