fix(acl): require the live scripted-alarm owner to match the generation's deployed owner (#797)
Review of 3d1b86b4: OpcUaPublishActor installs a new authority generation
before applying its address space, and an apply that throws keeps the old one.
A scripted alarm moved from equipment A to B therefore stayed commandable with
A's grant under B's generation (briefly before the rebuild, or indefinitely
after a failed apply).
The generation now carries ScriptedAlarmOwners (ScriptedAlarmId -> EquipmentId,
decoded by ParseDataPlaneAuthority from the artifact's ScriptedAlarms rows, the
same rows the composition materialises from). AuthorizeScriptedCondition denies
unless the node manager's live binding names the same owner as the generation:
a moved alarm is denied under the old owner's, the new owner's and a Cluster
grant until the address space re-binds it; an alarm the generation no longer
deploys is denied. Install/apply order unchanged.
Tests: window (a) generation moved / binding stale => both owners and Cluster
denied, then rebuild under the new owner => new owner allowed, old denied;
window (b) apply failed after install => denied across clock and further
generations, then a successful rebuild restores the new owner only; undeployed
alarm denied; artifact decode of the owner map.
This commit is contained in:
@@ -35,7 +35,8 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
IReadOnlyList<PermissionTrieBuildDiagnostic> droppedGrants,
|
||||
IReadOnlyDictionary<string, ApiKey> keysById,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal,
|
||||
IReadOnlyDictionary<string, NodeAclPath> equipmentScopePaths)
|
||||
IReadOnlyDictionary<string, NodeAclPath> equipmentScopePaths,
|
||||
IReadOnlyDictionary<string, string> scriptedAlarmOwners)
|
||||
{
|
||||
ClusterId = clusterId;
|
||||
RevisionHash = revisionHash;
|
||||
@@ -47,6 +48,7 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
_keysById = keysById;
|
||||
_grantsByPrincipal = grantsByPrincipal;
|
||||
EquipmentScopePaths = equipmentScopePaths;
|
||||
ScriptedAlarmOwners = scriptedAlarmOwners;
|
||||
}
|
||||
|
||||
private readonly IReadOnlyDictionary<string, ApiKey> _keysById;
|
||||
@@ -77,6 +79,31 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
/// </remarks>
|
||||
public IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths { get; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>ScriptedAlarmId → owning EquipmentId</c> as THIS generation's artifact deploys it (Gitea #797 review).
|
||||
/// Keyed <c>Ordinal</c> — the id is the condition's NodeId identifier, and NodeIds compare ordinally.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The node manager's owner binding describes the address space as last materialised; the publish actor
|
||||
/// installs a new generation BEFORE it applies that generation's address space, and an apply that throws
|
||||
/// after the install keeps the old address space. A scripted alarm moved from equipment A to B would
|
||||
/// otherwise stay commandable with A's grant under B's generation for that window — or until the next
|
||||
/// successful deploy. The authorizer therefore requires the live binding and this map to AGREE on the
|
||||
/// owner; any disagreement or absence denies until the address space catches up.
|
||||
/// </remarks>
|
||||
public IReadOnlyDictionary<string, string> ScriptedAlarmOwners { get; }
|
||||
|
||||
/// <summary>The owning <c>EquipmentId</c> this generation deploys <paramref name="scriptedAlarmId"/> under.</summary>
|
||||
/// <param name="scriptedAlarmId">The scripted alarm's id (its condition NodeId identifier).</param>
|
||||
/// <param name="equipmentId">The owner, when known.</param>
|
||||
/// <returns><see langword="true"/> when this generation deploys the alarm.</returns>
|
||||
public bool TryGetScriptedAlarmOwner(string scriptedAlarmId, out string equipmentId)
|
||||
{
|
||||
equipmentId = string.Empty;
|
||||
return !string.IsNullOrEmpty(scriptedAlarmId)
|
||||
&& ScriptedAlarmOwners.TryGetValue(scriptedAlarmId, out equipmentId!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The owning equipment's path WITHIN the UNS realm (the realm segment stripped), for a scripted
|
||||
/// condition bound to <paramref name="equipmentId"/>. False when this generation knows no such
|
||||
@@ -183,6 +210,8 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
/// <param name="droppedGrants">Grants the trie build discarded, or empty when no trie was built.</param>
|
||||
/// <param name="equipmentScopePaths">Every equipment's UNS ACL path (see <see cref="EquipmentScopePaths"/>);
|
||||
/// null ⇒ none, which denies every scripted-condition command under enforcement.</param>
|
||||
/// <param name="scriptedAlarmOwners"><c>ScriptedAlarmId → EquipmentId</c> as this generation deploys them
|
||||
/// (see <see cref="ScriptedAlarmOwners"/>); null ⇒ none.</param>
|
||||
/// <returns>The built snapshot.</returns>
|
||||
public static DataPlaneAuthoritySnapshot Create(
|
||||
string? clusterId,
|
||||
@@ -192,7 +221,8 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
IReadOnlyList<NodeAcl> grants,
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||
IReadOnlyList<PermissionTrieBuildDiagnostic>? droppedGrants = null,
|
||||
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null)
|
||||
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
|
||||
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(revisionHash);
|
||||
ArgumentNullException.ThrowIfNull(keys);
|
||||
@@ -239,9 +269,19 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
}
|
||||
}
|
||||
|
||||
var owners = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (scriptedAlarmOwners is not null)
|
||||
{
|
||||
foreach (var (alarmId, equipmentId) in scriptedAlarmOwners)
|
||||
{
|
||||
if (string.IsNullOrEmpty(alarmId) || string.IsNullOrWhiteSpace(equipmentId)) continue;
|
||||
owners[alarmId] = equipmentId;
|
||||
}
|
||||
}
|
||||
|
||||
return new DataPlaneAuthoritySnapshot(
|
||||
clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen,
|
||||
equipment);
|
||||
equipment, owners);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +349,8 @@ public interface IDataPlaneAuthoritySink
|
||||
/// <param name="scopePaths">Where each grant's scope sits in the address space.</param>
|
||||
/// <param name="equipmentScopePaths">Every equipment's UNS ACL path, resolved from the same artifact —
|
||||
/// the owner lookup for scripted alarm conditions (Gitea #797). Null ⇒ none.</param>
|
||||
/// <param name="scriptedAlarmOwners"><c>ScriptedAlarmId → EquipmentId</c> as the same artifact deploys
|
||||
/// them (Gitea #797 review). Null ⇒ none, which denies every scripted-condition command under enforcement.</param>
|
||||
/// <returns>The installed snapshot.</returns>
|
||||
DataPlaneAuthoritySnapshot Apply(
|
||||
string? clusterId,
|
||||
@@ -316,7 +358,8 @@ public interface IDataPlaneAuthoritySink
|
||||
IReadOnlyList<ApiKey> keys,
|
||||
IReadOnlyList<NodeAcl> grants,
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null);
|
||||
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
|
||||
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -365,7 +408,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
|
||||
IReadOnlyList<ApiKey> keys,
|
||||
IReadOnlyList<NodeAcl> grants,
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null)
|
||||
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
|
||||
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(revisionHash);
|
||||
|
||||
@@ -398,7 +442,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
|
||||
var dropped = BuildAndInstallTrie(clusterId, generation, grants, scopePaths);
|
||||
|
||||
published = DataPlaneAuthoritySnapshot.Create(
|
||||
clusterId, revisionHash, generation, keys, grants, scopePaths, dropped, equipmentScopePaths);
|
||||
clusterId, revisionHash, generation, keys, grants, scopePaths, dropped, equipmentScopePaths,
|
||||
scriptedAlarmOwners);
|
||||
Volatile.Write(ref _current, published);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user