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:
@@ -306,6 +306,18 @@ public sealed class DataPlaneAuthorizer
|
||||
"the scripted condition has no live owner binding (unknown, removed or replaced)");
|
||||
}
|
||||
|
||||
// The live binding describes the address space as last materialised; the generation is installed BEFORE
|
||||
// its address space is applied, and a failed apply keeps the old one. Require both to name the SAME owner
|
||||
// for this exact alarm id, so a moved alarm is denied — under its old AND its new owner's grant — until
|
||||
// the address space catches up, rather than staying commandable with the old owner's grant.
|
||||
if (!conditionNodeId.TryGetValue(out string alarmId)
|
||||
|| !snapshot.TryGetScriptedAlarmOwner(alarmId, out var deployedOwner)
|
||||
|| !string.Equals(deployedOwner, ownerEquipmentId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Deny(operation, conditionNodeId, identity.Principal,
|
||||
$"the live owner binding ({ownerEquipmentId}) does not match generation {snapshot.GenerationId}'s deployed owner of this scripted alarm");
|
||||
}
|
||||
|
||||
if (!snapshot.TryGetEquipmentUnsSegments(ownerEquipmentId, out var segments))
|
||||
{
|
||||
return Deny(operation, conditionNodeId, identity.Principal,
|
||||
|
||||
@@ -72,6 +72,16 @@ public sealed record DataPlaneAuthorityProjection(
|
||||
public IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths { get; init; } =
|
||||
new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// <c>ScriptedAlarmId → EquipmentId</c> from the artifact's <c>ScriptedAlarms</c> rows — the same rows the
|
||||
/// applier's composition materialises scripted conditions from. Installed with the generation so the
|
||||
/// authorizer can require the address space's live owner binding to agree with what THIS generation
|
||||
/// deploys (Gitea #797 review: a moved alarm must not stay commandable with its old owner's grant while
|
||||
/// the address space lags the generation).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> ScriptedAlarmOwners { get; init; } =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
|
||||
public static readonly DataPlaneAuthorityProjection Empty =
|
||||
new(null, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
||||
@@ -918,9 +928,21 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
}
|
||||
|
||||
var (scopePaths, equipmentScopePaths) = BuildAclScopePaths(root, grants);
|
||||
// Every scripted alarm's owner, from the rows the address space is composed from. Read regardless of
|
||||
// the grant count: it is a topology fact, cheap, and an absent map only ever denies.
|
||||
var scriptedAlarmOwners = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "ScriptedAlarms"))
|
||||
{
|
||||
var alarmId = ReadString(el, "ScriptedAlarmId");
|
||||
var equipmentId = ReadString(el, "EquipmentId");
|
||||
if (string.IsNullOrEmpty(alarmId) || string.IsNullOrWhiteSpace(equipmentId)) continue;
|
||||
scriptedAlarmOwners[alarmId] = equipmentId;
|
||||
}
|
||||
|
||||
return new DataPlaneAuthorityProjection(scope.ClusterId, keys, grants, scopePaths)
|
||||
{
|
||||
EquipmentScopePaths = equipmentScopePaths,
|
||||
ScriptedAlarmOwners = scriptedAlarmOwners,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1426,7 +1426,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes));
|
||||
var snapshot = _dataPlaneAuthority.Apply(
|
||||
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths,
|
||||
projection.EquipmentScopePaths);
|
||||
projection.EquipmentScopePaths, projection.ScriptedAlarmOwners);
|
||||
|
||||
_log.Info(
|
||||
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",
|
||||
|
||||
Reference in New Issue
Block a user