using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations; /// /// Stored procedures per config-db-schema.md §"Stored Procedures". All node + admin DB /// access funnels through these — direct table writes are revoked in the AuthorizationGrants /// migration that follows. CREATE OR ALTER style so procs version with the schema. /// public partial class StoredProcedures : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.Sql(Procs.GetCurrentGenerationForCluster); migrationBuilder.Sql(Procs.GetGenerationContent); migrationBuilder.Sql(Procs.RegisterNodeGenerationApplied); migrationBuilder.Sql(Procs.ValidateDraft); migrationBuilder.Sql(Procs.PublishGeneration); migrationBuilder.Sql(Procs.RollbackToGeneration); migrationBuilder.Sql(Procs.ComputeGenerationDiff); migrationBuilder.Sql(Procs.ReleaseExternalIdReservation); } protected override void Down(MigrationBuilder migrationBuilder) { foreach (var name in new[] { "sp_ReleaseExternalIdReservation", "sp_ComputeGenerationDiff", "sp_RollbackToGeneration", "sp_PublishGeneration", "sp_ValidateDraft", "sp_RegisterNodeGenerationApplied", "sp_GetGenerationContent", "sp_GetCurrentGenerationForCluster", }) { migrationBuilder.Sql($"IF OBJECT_ID(N'dbo.{name}', N'P') IS NOT NULL DROP PROCEDURE dbo.{name};"); } } private static class Procs { public const string GetCurrentGenerationForCluster = @" CREATE OR ALTER PROCEDURE dbo.sp_GetCurrentGenerationForCluster @NodeId nvarchar(64), @ClusterId nvarchar(64) AS BEGIN SET NOCOUNT ON; DECLARE @Caller nvarchar(128) = SUSER_SNAME(); IF NOT EXISTS ( SELECT 1 FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1) BEGIN RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId); RETURN; END IF NOT EXISTS ( SELECT 1 FROM dbo.ClusterNode WHERE NodeId = @NodeId AND ClusterId = @ClusterId AND Enabled = 1) BEGIN RAISERROR('Forbidden: NodeId %s does not belong to ClusterId %s', 16, 1, @NodeId, @ClusterId); RETURN; END SELECT TOP 1 GenerationId, ClusterId, Status, PublishedAt, PublishedBy, Notes FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId AND Status = 'Published' ORDER BY GenerationId DESC; END "; public const string GetGenerationContent = @" CREATE OR ALTER PROCEDURE dbo.sp_GetGenerationContent @NodeId nvarchar(64), @GenerationId bigint AS BEGIN SET NOCOUNT ON; DECLARE @Caller nvarchar(128) = SUSER_SNAME(); DECLARE @ClusterId nvarchar(64); SELECT @ClusterId = ClusterId FROM dbo.ConfigGeneration WHERE GenerationId = @GenerationId; IF @ClusterId IS NULL BEGIN RAISERROR('GenerationId %I64d not found', 16, 1, @GenerationId); RETURN; END IF NOT EXISTS ( SELECT 1 FROM dbo.ClusterNodeCredential c JOIN dbo.ClusterNode n ON n.NodeId = c.NodeId WHERE c.NodeId = @NodeId AND c.Value = @Caller AND c.Enabled = 1 AND n.ClusterId = @ClusterId AND n.Enabled = 1) BEGIN RAISERROR('Forbidden: caller %s not bound to a node in ClusterId %s', 16, 1, @Caller, @ClusterId); RETURN; END SELECT * FROM dbo.Namespace WHERE GenerationId = @GenerationId; SELECT * FROM dbo.UnsArea WHERE GenerationId = @GenerationId; SELECT * FROM dbo.UnsLine WHERE GenerationId = @GenerationId; SELECT * FROM dbo.DriverInstance WHERE GenerationId = @GenerationId; SELECT * FROM dbo.Device WHERE GenerationId = @GenerationId; SELECT * FROM dbo.Equipment WHERE GenerationId = @GenerationId; SELECT * FROM dbo.PollGroup WHERE GenerationId = @GenerationId; SELECT * FROM dbo.Tag WHERE GenerationId = @GenerationId; SELECT * FROM dbo.NodeAcl WHERE GenerationId = @GenerationId; END "; public const string RegisterNodeGenerationApplied = @" CREATE OR ALTER PROCEDURE dbo.sp_RegisterNodeGenerationApplied @NodeId nvarchar(64), @GenerationId bigint, @Status nvarchar(16), @Error nvarchar(max) = NULL AS BEGIN SET NOCOUNT ON; DECLARE @Caller nvarchar(128) = SUSER_SNAME(); IF NOT EXISTS ( SELECT 1 FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1) BEGIN RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId); RETURN; END MERGE dbo.ClusterNodeGenerationState AS tgt USING (SELECT @NodeId AS NodeId) AS src ON tgt.NodeId = src.NodeId WHEN MATCHED THEN UPDATE SET CurrentGenerationId = @GenerationId, LastAppliedAt = SYSUTCDATETIME(), LastAppliedStatus = @Status, LastAppliedError = @Error, LastSeenAt = SYSUTCDATETIME() WHEN NOT MATCHED THEN INSERT (NodeId, CurrentGenerationId, LastAppliedAt, LastAppliedStatus, LastAppliedError, LastSeenAt) VALUES (@NodeId, @GenerationId, SYSUTCDATETIME(), @Status, @Error, SYSUTCDATETIME()); INSERT dbo.ConfigAuditLog (Principal, EventType, NodeId, GenerationId, DetailsJson) VALUES (@Caller, 'NodeApplied', @NodeId, @GenerationId, CONCAT('{""status"":""', @Status, '""}')); END "; public const string ValidateDraft = @" CREATE OR ALTER PROCEDURE dbo.sp_ValidateDraft @DraftGenerationId bigint AS BEGIN SET NOCOUNT ON; DECLARE @ClusterId nvarchar(64); DECLARE @Status nvarchar(16); SELECT @ClusterId = ClusterId, @Status = Status FROM dbo.ConfigGeneration WHERE GenerationId = @DraftGenerationId; IF @ClusterId IS NULL BEGIN RAISERROR('GenerationId %I64d not found', 16, 1, @DraftGenerationId); RETURN; END IF @Status <> 'Draft' BEGIN RAISERROR('GenerationId %I64d is not in Draft status (current=%s)', 16, 1, @DraftGenerationId, @Status); RETURN; END IF EXISTS ( SELECT 1 FROM dbo.Tag t LEFT JOIN dbo.DriverInstance d ON d.GenerationId = t.GenerationId AND d.DriverInstanceId = t.DriverInstanceId WHERE t.GenerationId = @DraftGenerationId AND d.DriverInstanceId IS NULL) BEGIN RAISERROR('Draft has tags with unresolved DriverInstanceId', 16, 1); RETURN; END IF EXISTS ( SELECT 1 FROM dbo.Tag t LEFT JOIN dbo.Device dv ON dv.GenerationId = t.GenerationId AND dv.DeviceId = t.DeviceId WHERE t.GenerationId = @DraftGenerationId AND t.DeviceId IS NOT NULL AND dv.DeviceId IS NULL) BEGIN RAISERROR('Draft has tags with unresolved DeviceId', 16, 1); RETURN; END IF EXISTS ( SELECT 1 FROM dbo.Tag t LEFT JOIN dbo.PollGroup pg ON pg.GenerationId = t.GenerationId AND pg.PollGroupId = t.PollGroupId WHERE t.GenerationId = @DraftGenerationId AND t.PollGroupId IS NOT NULL AND pg.PollGroupId IS NULL) BEGIN RAISERROR('Draft has tags with unresolved PollGroupId', 16, 1); RETURN; END IF EXISTS ( SELECT 1 FROM dbo.DriverInstance di JOIN dbo.Namespace ns ON ns.GenerationId = di.GenerationId AND ns.NamespaceId = di.NamespaceId WHERE di.GenerationId = @DraftGenerationId AND ns.ClusterId <> di.ClusterId) BEGIN INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId) VALUES (SUSER_SNAME(), 'CrossClusterNamespaceAttempt', @ClusterId, @DraftGenerationId); RAISERROR('BadCrossClusterNamespaceBinding: namespace and driver must belong to the same cluster', 16, 1); RETURN; END IF EXISTS ( SELECT 1 FROM dbo.Equipment draft JOIN dbo.Equipment prior ON prior.EquipmentId = draft.EquipmentId AND prior.EquipmentUuid <> draft.EquipmentUuid AND prior.GenerationId <> draft.GenerationId JOIN dbo.ConfigGeneration pg ON pg.GenerationId = prior.GenerationId WHERE draft.GenerationId = @DraftGenerationId AND pg.ClusterId = @ClusterId) BEGIN RAISERROR('EquipmentUuid immutability violated for an EquipmentId that existed in a prior generation', 16, 1); RETURN; END IF EXISTS ( SELECT 1 FROM dbo.Equipment draft JOIN dbo.ExternalIdReservation r ON r.Kind = 'ZTag' AND r.Value = draft.ZTag AND r.ReleasedAt IS NULL AND r.EquipmentUuid <> draft.EquipmentUuid WHERE draft.GenerationId = @DraftGenerationId AND draft.ZTag IS NOT NULL) BEGIN RAISERROR('BadDuplicateExternalIdentifier: a ZTag in the draft is reserved by a different EquipmentUuid', 16, 1); RETURN; END IF EXISTS ( SELECT 1 FROM dbo.Equipment draft JOIN dbo.ExternalIdReservation r ON r.Kind = 'SAPID' AND r.Value = draft.SAPID AND r.ReleasedAt IS NULL AND r.EquipmentUuid <> draft.EquipmentUuid WHERE draft.GenerationId = @DraftGenerationId AND draft.SAPID IS NOT NULL) BEGIN RAISERROR('BadDuplicateExternalIdentifier: a SAPID in the draft is reserved by a different EquipmentUuid', 16, 1); RETURN; END END "; public const string PublishGeneration = @" CREATE OR ALTER PROCEDURE dbo.sp_PublishGeneration @ClusterId nvarchar(64), @DraftGenerationId bigint, @Notes nvarchar(1024) = NULL AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; BEGIN TRANSACTION; DECLARE @Lock nvarchar(255) = N'OtOpcUa_Publish_' + @ClusterId; DECLARE @LockResult int; EXEC @LockResult = sp_getapplock @Resource = @Lock, @LockMode = 'Exclusive', @LockTimeout = 0; IF @LockResult < 0 BEGIN RAISERROR('PublishConflict: another publish is in progress for cluster %s', 16, 1, @ClusterId); ROLLBACK; RETURN; END EXEC dbo.sp_ValidateDraft @DraftGenerationId = @DraftGenerationId; MERGE dbo.ExternalIdReservation AS tgt USING ( SELECT 'ZTag' AS Kind, ZTag AS Value, EquipmentUuid FROM dbo.Equipment WHERE GenerationId = @DraftGenerationId AND ZTag IS NOT NULL UNION ALL SELECT 'SAPID', SAPID, EquipmentUuid FROM dbo.Equipment WHERE GenerationId = @DraftGenerationId AND SAPID IS NOT NULL ) AS src ON tgt.Kind = src.Kind AND tgt.Value = src.Value AND tgt.EquipmentUuid = src.EquipmentUuid WHEN MATCHED THEN UPDATE SET LastPublishedAt = SYSUTCDATETIME() WHEN NOT MATCHED BY TARGET THEN INSERT (Kind, Value, EquipmentUuid, ClusterId, FirstPublishedBy, LastPublishedAt) VALUES (src.Kind, src.Value, src.EquipmentUuid, @ClusterId, SUSER_SNAME(), SYSUTCDATETIME()); UPDATE dbo.ConfigGeneration SET Status = 'Superseded' WHERE ClusterId = @ClusterId AND Status = 'Published'; UPDATE dbo.ConfigGeneration SET Status = 'Published', PublishedAt = SYSUTCDATETIME(), PublishedBy = SUSER_SNAME(), Notes = ISNULL(@Notes, Notes) WHERE GenerationId = @DraftGenerationId AND ClusterId = @ClusterId AND Status = 'Draft'; IF @@ROWCOUNT = 0 BEGIN RAISERROR('Draft %I64d for cluster %s not found (was it already published?)', 16, 1, @DraftGenerationId, @ClusterId); ROLLBACK; RETURN; END INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId) VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId); COMMIT; END "; public const string RollbackToGeneration = @" CREATE OR ALTER PROCEDURE dbo.sp_RollbackToGeneration @ClusterId nvarchar(64), @TargetGenerationId bigint, @Notes nvarchar(1024) = NULL AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; BEGIN TRANSACTION; IF NOT EXISTS ( SELECT 1 FROM dbo.ConfigGeneration WHERE GenerationId = @TargetGenerationId AND ClusterId = @ClusterId AND Status IN ('Published', 'Superseded')) BEGIN RAISERROR('Target generation %I64d not found or not rollback-eligible', 16, 1, @TargetGenerationId); ROLLBACK; RETURN; END DECLARE @NewGenId bigint; INSERT dbo.ConfigGeneration (ClusterId, Status, CreatedAt, CreatedBy, PublishedAt, PublishedBy, Notes) VALUES (@ClusterId, 'Draft', SYSUTCDATETIME(), SUSER_SNAME(), NULL, NULL, ISNULL(@Notes, CONCAT('Rollback clone of generation ', @TargetGenerationId))); SET @NewGenId = SCOPE_IDENTITY(); INSERT dbo.Namespace (GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes) SELECT @NewGenId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes FROM dbo.Namespace WHERE GenerationId = @TargetGenerationId; INSERT dbo.UnsArea (GenerationId, UnsAreaId, ClusterId, Name, Notes) SELECT @NewGenId, UnsAreaId, ClusterId, Name, Notes FROM dbo.UnsArea WHERE GenerationId = @TargetGenerationId; INSERT dbo.UnsLine (GenerationId, UnsLineId, UnsAreaId, Name, Notes) SELECT @NewGenId, UnsLineId, UnsAreaId, Name, Notes FROM dbo.UnsLine WHERE GenerationId = @TargetGenerationId; INSERT dbo.DriverInstance (GenerationId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig) SELECT @NewGenId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig FROM dbo.DriverInstance WHERE GenerationId = @TargetGenerationId; INSERT dbo.Device (GenerationId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig) SELECT @NewGenId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig FROM dbo.Device WHERE GenerationId = @TargetGenerationId; INSERT dbo.Equipment (GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled) SELECT @NewGenId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled FROM dbo.Equipment WHERE GenerationId = @TargetGenerationId; INSERT dbo.PollGroup (GenerationId, PollGroupId, DriverInstanceId, Name, IntervalMs) SELECT @NewGenId, PollGroupId, DriverInstanceId, Name, IntervalMs FROM dbo.PollGroup WHERE GenerationId = @TargetGenerationId; INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig) SELECT @NewGenId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig FROM dbo.Tag WHERE GenerationId = @TargetGenerationId; INSERT dbo.NodeAcl (GenerationId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes) SELECT @NewGenId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes FROM dbo.NodeAcl WHERE GenerationId = @TargetGenerationId; EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @NewGenId, @Notes = @Notes; INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId, DetailsJson) VALUES (SUSER_SNAME(), 'RolledBack', @ClusterId, @NewGenId, CONCAT('{""rolledBackTo"":', @TargetGenerationId, '}')); COMMIT; END "; public const string ComputeGenerationDiff = @" CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff @FromGenerationId bigint, @ToGenerationId bigint AS BEGIN SET NOCOUNT ON; CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16)); WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId), t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId) INSERT #diff SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)), CASE WHEN f.LogicalId IS NULL THEN 'Added' WHEN t.LogicalId IS NULL THEN 'Removed' WHEN f.Sig <> t.Sig THEN 'Modified' ELSE 'Unchanged' END FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig; WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId), t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId) INSERT #diff SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)), CASE WHEN f.LogicalId IS NULL THEN 'Added' WHEN t.LogicalId IS NULL THEN 'Removed' WHEN f.Sig <> t.Sig THEN 'Modified' ELSE 'Unchanged' END FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig; WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId), t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId) INSERT #diff SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)), CASE WHEN f.LogicalId IS NULL THEN 'Added' WHEN t.LogicalId IS NULL THEN 'Removed' WHEN f.Sig <> t.Sig THEN 'Modified' ELSE 'Unchanged' END FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig; WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId), t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId) INSERT #diff SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)), CASE WHEN f.LogicalId IS NULL THEN 'Added' WHEN t.LogicalId IS NULL THEN 'Removed' WHEN f.Sig <> t.Sig THEN 'Modified' ELSE 'Unchanged' END FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig; SELECT TableName, LogicalId, ChangeKind FROM #diff; DROP TABLE #diff; END "; public const string ReleaseExternalIdReservation = @" CREATE OR ALTER PROCEDURE dbo.sp_ReleaseExternalIdReservation @Kind nvarchar(16), @Value nvarchar(64), @ReleaseReason nvarchar(512) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; IF @ReleaseReason IS NULL OR LEN(@ReleaseReason) = 0 BEGIN RAISERROR('ReleaseReason is required', 16, 1); RETURN; END UPDATE dbo.ExternalIdReservation SET ReleasedAt = SYSUTCDATETIME(), ReleasedBy = SUSER_SNAME(), ReleaseReason = @ReleaseReason WHERE Kind = @Kind AND Value = @Value AND ReleasedAt IS NULL; IF @@ROWCOUNT = 0 BEGIN RAISERROR('No active reservation found for (%s, %s)', 16, 1, @Kind, @Value); RETURN; END INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson) VALUES (SUSER_SNAME(), 'ExternalIdReleased', CONCAT('{""kind"":""', @Kind, '"",""value"":""', @Value, '""}')); END "; } }