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:
+21
-4
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user