refactor(acl): NodeAcl.LdapGroup -> Principal, with the proc that reads it (#520)
Stage 1a of the API-key ACL design. Behaviour-neutral: the trie walker already matched an opaque string, so only what produces that string changes. No enforcement is wired by this commit and none of the two live role gates move. Renamed through the ACL model: NodeAcl.Principal, TrieGrant.Principal, MatchedGrant.Principal, AuthorizationDecision.Principal, UserAuthorizationState.Principals, and PermissionTrie.CollectMatches's parameter. LdapGroupRoleMapping is a DIFFERENT entity that also has an LdapGroup column and is deliberately untouched — it backs AdminUI login, which keeps LDAP. The migration is the interesting part. EF scaffolds RenameColumn/RenameIndex, so rows survive — but it cannot know that dbo.sp_ComputeGenerationDiff checksums NodeAcl.LdapGroup. That proc is created in V3Initial.StoredProcedures and never redefined since, and SQL Server does not validate a procedure body when a column is renamed out from under it: the object keeps reporting healthy and fails at CALL time with Invalid column name, silently breaking config-drift detection. Verified against the live dev DB that the deployed proc really does reference the column. The migration therefore re-emits the proc, as one parameterised template shared by Up and Down so the two cannot drift. Prose that describes what is enforced TODAY (fleet-wide LDAP group -> role) is left alone, because it is still accurate until stage 2 lands. UserAuthorizationState's refresh machinery is retained unchanged but now carries a note that it is LDAP-shaped and largely vestigial here: an API-key principal set cannot change out of band, since keys and ACL rows both ride the deployment artifact, so the generation stamp rather than the freshness timer is the real invalidation signal. 41 authorization tests pass; solution builds clean. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One ACL grant: an LDAP group gets a set of <see cref="NodePermissions"/> at a specific scope.
|
||||
/// One ACL grant: a principal gets a set of <see cref="NodePermissions"/> at a specific scope.
|
||||
/// Generation-versioned per decision. See <c>acl-design.md</c> for evaluation algorithm.
|
||||
/// </summary>
|
||||
public sealed class NodeAcl
|
||||
@@ -17,8 +17,8 @@ public sealed class NodeAcl
|
||||
/// <summary>Gets or sets the cluster ID for this ACL entry.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the LDAP group for this ACL entry.</summary>
|
||||
public required string LdapGroup { get; set; }
|
||||
/// <summary>Gets or sets the principal (API-key principal name, or the reserved <c>anonymous</c>) this ACL entry grants to.</summary>
|
||||
public required string Principal { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the scope kind for this ACL entry.</summary>
|
||||
public required NodeAclScopeKind ScopeKind { get; set; }
|
||||
|
||||
+1560
File diff suppressed because it is too large
Load Diff
+104
@@ -0,0 +1,104 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// #520 — <c>NodeAcl.LdapGroup</c> becomes <c>NodeAcl.Principal</c>. The OPC UA data plane is
|
||||
/// machine-to-machine, so an ACL grant keys on an API-key principal (or the reserved
|
||||
/// <c>anonymous</c>), not on an LDAP group. EF scaffolds this as RenameColumn/RenameIndex, so
|
||||
/// existing rows survive.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ The column is also read by <c>dbo.sp_ComputeGenerationDiff</c>, created in
|
||||
/// <c>V3Initial.StoredProcedures</c> and never redefined since. A bare column rename leaves that
|
||||
/// proc referencing a column that no longer exists — SQL Server does not validate a procedure
|
||||
/// body at rename time, so the object keeps reporting healthy and fails at CALL time with
|
||||
/// <c>Invalid column name 'LdapGroup'</c>, silently breaking config-drift detection. The proc is
|
||||
/// therefore re-emitted below in the same migration, body otherwise byte-identical. If you add or
|
||||
/// rename a column on any table that proc checksums, it needs the same treatment.
|
||||
/// </remarks>
|
||||
public partial class RenameNodeAclLdapGroupToPrincipal : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "LdapGroup",
|
||||
table: "NodeAcl",
|
||||
newName: "Principal");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "UX_NodeAcl_GroupScope",
|
||||
table: "NodeAcl",
|
||||
newName: "UX_NodeAcl_PrincipalScope");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_NodeAcl_Group",
|
||||
table: "NodeAcl",
|
||||
newName: "IX_NodeAcl_Principal");
|
||||
|
||||
migrationBuilder.Sql(ComputeGenerationDiff("Principal"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "Principal",
|
||||
table: "NodeAcl",
|
||||
newName: "LdapGroup");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "UX_NodeAcl_PrincipalScope",
|
||||
table: "NodeAcl",
|
||||
newName: "UX_NodeAcl_GroupScope");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_NodeAcl_Principal",
|
||||
table: "NodeAcl",
|
||||
newName: "IX_NodeAcl_Group");
|
||||
|
||||
migrationBuilder.Sql(ComputeGenerationDiff("LdapGroup"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>dbo.sp_ComputeGenerationDiff</c> re-emitted with the NodeAcl grantee column named by
|
||||
/// <paramref name="granteeColumn"/>. Kept as one parameterised template so Up and Down cannot
|
||||
/// drift from each other, and so the diff shows exactly one token changing.
|
||||
/// </summary>
|
||||
/// <param name="granteeColumn">The NodeAcl grantee column name to checksum.</param>
|
||||
/// <returns>The <c>CREATE OR ALTER PROCEDURE</c> statement.</returns>
|
||||
private static string ComputeGenerationDiff(string granteeColumn) => $@"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
-- @FromGenerationId / @ToGenerationId are ignored: the v3 config tables are single live-state
|
||||
-- (no GenerationId column). Emit a current-state signature per row so the shape (TableName,
|
||||
-- LogicalId, Sig) still round-trips through any legacy caller.
|
||||
SELECT TableName, LogicalId, Sig FROM (
|
||||
SELECT 'RawFolder' AS TableName, RawFolderId AS LogicalId, CHECKSUM(ClusterId, ParentRawFolderId, Name, SortOrder) AS Sig FROM dbo.RawFolder
|
||||
UNION ALL
|
||||
SELECT 'DriverInstance', DriverInstanceId, CHECKSUM(ClusterId, RawFolderId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) FROM dbo.DriverInstance
|
||||
UNION ALL
|
||||
SELECT 'Device', DeviceId, CHECKSUM(DriverInstanceId, Name, Enabled, CONVERT(varchar(max), DeviceConfig)) FROM dbo.Device
|
||||
UNION ALL
|
||||
SELECT 'TagGroup', TagGroupId, CHECKSUM(DeviceId, ParentTagGroupId, Name, SortOrder) FROM dbo.TagGroup
|
||||
UNION ALL
|
||||
SELECT 'Tag', TagId, CHECKSUM(DeviceId, TagGroupId, PollGroupId, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) FROM dbo.Tag
|
||||
UNION ALL
|
||||
SELECT 'Equipment', EquipmentId, CHECKSUM(EquipmentUuid, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) FROM dbo.Equipment
|
||||
UNION ALL
|
||||
SELECT 'UnsTagReference', UnsTagReferenceId, CHECKSUM(EquipmentId, TagId, DisplayNameOverride, SortOrder) FROM dbo.UnsTagReference
|
||||
UNION ALL
|
||||
SELECT 'NodeAcl', NodeAclId, CHECKSUM(ClusterId, {granteeColumn}, ScopeKind, ScopeId, PermissionFlags, Notes) FROM dbo.NodeAcl
|
||||
) AS inventory;
|
||||
END
|
||||
";
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -709,11 +709,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("LdapGroup")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("NodeAclId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
@@ -725,6 +720,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
b.Property<int>("PermissionFlags")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Principal")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
@@ -745,21 +745,21 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
b.HasIndex("ClusterId")
|
||||
.HasDatabaseName("IX_NodeAcl_Cluster");
|
||||
|
||||
b.HasIndex("LdapGroup")
|
||||
.HasDatabaseName("IX_NodeAcl_Group");
|
||||
|
||||
b.HasIndex("NodeAclId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_NodeAcl_LogicalId")
|
||||
.HasFilter("[NodeAclId] IS NOT NULL");
|
||||
|
||||
b.HasIndex("Principal")
|
||||
.HasDatabaseName("IX_NodeAcl_Principal");
|
||||
|
||||
b.HasIndex("ScopeKind", "ScopeId")
|
||||
.HasDatabaseName("IX_NodeAcl_Scope")
|
||||
.HasFilter("[ScopeId] IS NOT NULL");
|
||||
|
||||
b.HasIndex("ClusterId", "LdapGroup", "ScopeKind", "ScopeId")
|
||||
b.HasIndex("ClusterId", "Principal", "ScopeKind", "ScopeId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_NodeAcl_GroupScope")
|
||||
.HasDatabaseName("UX_NodeAcl_PrincipalScope")
|
||||
.HasFilter("[ScopeId] IS NOT NULL");
|
||||
|
||||
b.ToTable("NodeAcl", (string)null);
|
||||
|
||||
@@ -449,7 +449,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
e.Property(x => x.NodeAclRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.NodeAclId).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.LdapGroup).HasMaxLength(256);
|
||||
e.Property(x => x.Principal).HasMaxLength(256);
|
||||
e.Property(x => x.ScopeKind).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.ScopeId).HasMaxLength(64);
|
||||
e.Property(x => x.PermissionFlags).HasConversion<int>();
|
||||
@@ -457,13 +457,13 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
e.Property(x => x.RowVersion).IsRowVersion();
|
||||
|
||||
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_NodeAcl_Cluster");
|
||||
e.HasIndex(x => x.LdapGroup).HasDatabaseName("IX_NodeAcl_Group");
|
||||
e.HasIndex(x => x.Principal).HasDatabaseName("IX_NodeAcl_Principal");
|
||||
e.HasIndex(x => new { x.ScopeKind, x.ScopeId })
|
||||
.HasFilter("[ScopeId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_NodeAcl_Scope");
|
||||
e.HasIndex(x => x.NodeAclId).IsUnique().HasDatabaseName("UX_NodeAcl_LogicalId");
|
||||
e.HasIndex(x => new { x.ClusterId, x.LdapGroup, x.ScopeKind, x.ScopeId }).IsUnique()
|
||||
.HasDatabaseName("UX_NodeAcl_GroupScope");
|
||||
e.HasIndex(x => new { x.ClusterId, x.Principal, x.ScopeKind, x.ScopeId }).IsUnique()
|
||||
.HasDatabaseName("UX_NodeAcl_PrincipalScope");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user