From 3ca64afa9da73cfdd24b1a8ddb9fa12852145788 Mon Sep 17 00:00:00 2001 From: Oleksandr Slynko Date: Tue, 14 Apr 2026 10:24:33 +0100 Subject: [PATCH 1/3] Move managed identity policy parsing from Kusto to C# Extract ParseFromPolicyJson static method that parses raw JSON from .show database policy managed_identity. Simplify the Kusto query to just project the Policy column. Add ClientId property (YamlIgnore) to support future principal normalization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Model/ManagedIdentityPolicyTests.cs | 103 ++++++++++++++++++ .../Model/ManagedIdentityPolicy.cs | 38 +++++++ .../KustoManagedIdentityPolicyLoader.cs | 28 ++--- 3 files changed, 148 insertions(+), 21 deletions(-) diff --git a/KustoSchemaTools.Tests/Model/ManagedIdentityPolicyTests.cs b/KustoSchemaTools.Tests/Model/ManagedIdentityPolicyTests.cs index 66e6ff0..0f87a0d 100644 --- a/KustoSchemaTools.Tests/Model/ManagedIdentityPolicyTests.cs +++ b/KustoSchemaTools.Tests/Model/ManagedIdentityPolicyTests.cs @@ -5,6 +5,109 @@ namespace KustoSchemaTools.Tests.ManagedIdentity { + public class ManagedIdentityPolicyParseTests + { + [Fact] + public void ParseFromPolicyJson_SinglePolicy_ExtractsObjectIdAndClientId() + { + var json = @"[ + { + ""ObjectId"": ""8749feae-888c-446b-9f38-26f0c38ba1cd"", + ""ClientId"": ""1de2a36c-bba4-4380-be8d-5f400303219b"", + ""TenantId"": ""398a6654-997b-47e9-b12b-9515b896b4de"", + ""DisplayName"": ""my-identity"", + ""IsSystem"": false, + ""AllowedUsages"": ""AutomatedFlows"" + } + ]"; + + var result = ManagedIdentityPolicy.ParseFromPolicyJson(json); + + Assert.Single(result); + Assert.Equal("8749feae-888c-446b-9f38-26f0c38ba1cd", result[0].ObjectId); + Assert.Equal("1de2a36c-bba4-4380-be8d-5f400303219b", result[0].ClientId); + Assert.Equal(new List { "AutomatedFlows" }, result[0].AllowedUsages); + } + + [Fact] + public void ParseFromPolicyJson_MultipleUsages_SplitsAndSortsAlphabetically() + { + var json = @"[ + { + ""ObjectId"": ""aaaa"", + ""ClientId"": ""bbbb"", + ""AllowedUsages"": ""NativeIngestion, AutomatedFlows, ExternalTable"" + } + ]"; + + var result = ManagedIdentityPolicy.ParseFromPolicyJson(json); + + Assert.Equal(new List { "AutomatedFlows", "ExternalTable", "NativeIngestion" }, result[0].AllowedUsages); + } + + [Fact] + public void ParseFromPolicyJson_MultiplePolicies_SortsByObjectId() + { + var json = @"[ + { ""ObjectId"": ""zzzz"", ""ClientId"": ""c1"", ""AllowedUsages"": ""ExternalTable"" }, + { ""ObjectId"": ""aaaa"", ""ClientId"": ""c2"", ""AllowedUsages"": ""NativeIngestion"" } + ]"; + + var result = ManagedIdentityPolicy.ParseFromPolicyJson(json); + + Assert.Equal(2, result.Count); + Assert.Equal("aaaa", result[0].ObjectId); + Assert.Equal("zzzz", result[1].ObjectId); + } + + [Fact] + public void ParseFromPolicyJson_EmptyJson_ReturnsEmptyList() + { + Assert.Empty(ManagedIdentityPolicy.ParseFromPolicyJson("[]")); + } + + [Fact] + public void ParseFromPolicyJson_NullOrWhitespace_ReturnsEmptyList() + { + Assert.Empty(ManagedIdentityPolicy.ParseFromPolicyJson(null)); + Assert.Empty(ManagedIdentityPolicy.ParseFromPolicyJson("")); + Assert.Empty(ManagedIdentityPolicy.ParseFromPolicyJson(" ")); + } + + [Fact] + public void ParseFromPolicyJson_NullClientId_SetsClientIdToNull() + { + var json = @"[{ ""ObjectId"": ""aaaa"", ""AllowedUsages"": ""AutomatedFlows"" }]"; + + var result = ManagedIdentityPolicy.ParseFromPolicyJson(json); + + Assert.Single(result); + Assert.Equal("aaaa", result[0].ObjectId); + Assert.Null(result[0].ClientId); + } + + [Fact] + public void ParseFromPolicyJson_IgnoresExtraFields() + { + var json = @"[ + { + ""ObjectId"": ""aaaa"", + ""ClientId"": ""bbbb"", + ""TenantId"": ""tttt"", + ""DisplayName"": ""my-identity"", + ""IsSystem"": false, + ""AllowedUsages"": ""AutomatedFlows"" + } + ]"; + + var result = ManagedIdentityPolicy.ParseFromPolicyJson(json); + + Assert.Single(result); + Assert.Equal("aaaa", result[0].ObjectId); + Assert.Equal("bbbb", result[0].ClientId); + } + } + public class ManagedIdentityPolicyTests { [Fact] diff --git a/KustoSchemaTools/Model/ManagedIdentityPolicy.cs b/KustoSchemaTools/Model/ManagedIdentityPolicy.cs index cd5493e..39d9547 100644 --- a/KustoSchemaTools/Model/ManagedIdentityPolicy.cs +++ b/KustoSchemaTools/Model/ManagedIdentityPolicy.cs @@ -2,6 +2,7 @@ using Newtonsoft.Json; using KustoSchemaTools.Helpers; using KustoSchemaTools.Parser; +using YamlDotNet.Serialization; namespace KustoSchemaTools.Model { @@ -10,6 +11,36 @@ public class ManagedIdentityPolicy public string ObjectId { get; set; } public List AllowedUsages { get; set; } = new List(); + [YamlIgnore] + public string ClientId { get; set; } + + /// + /// Parses the raw JSON Policy column from .show database policy managed_identity + /// into a list of ManagedIdentityPolicy objects. + /// + public static List ParseFromPolicyJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return new List(); + + var rawPolicies = JsonConvert.DeserializeObject>(json); + if (rawPolicies == null) + return new List(); + + return rawPolicies + .Select(p => new ManagedIdentityPolicy + { + ObjectId = p.ObjectId, + ClientId = p.ClientId, + AllowedUsages = (p.AllowedUsages ?? "") + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .OrderBy(u => u) + .ToList() + }) + .OrderBy(p => p.ObjectId) + .ToList(); + } + /// /// Creates a single script that sets managed identity policy for all provided identities. /// Uses one combined command to avoid duplicate Kind keys in the diff pipeline. @@ -23,5 +54,12 @@ public static DatabaseScriptContainer CreateCombinedScript(string databaseName, var json = JsonConvert.SerializeObject(policyObjects, Serialization.JsonPascalCase); return new DatabaseScriptContainer("ManagedIdentityPolicy", 80, $".alter-merge database {databaseName.BracketIfIdentifier()} policy managed_identity ```{json}```"); } + + private class RawManagedIdentityPolicy + { + public string ObjectId { get; set; } + public string ClientId { get; set; } + public string AllowedUsages { get; set; } + } } } diff --git a/KustoSchemaTools/Parser/KustoLoader/KustoManagedIdentityPolicyLoader.cs b/KustoSchemaTools/Parser/KustoLoader/KustoManagedIdentityPolicyLoader.cs index bfbb019..9d5a4c4 100644 --- a/KustoSchemaTools/Parser/KustoLoader/KustoManagedIdentityPolicyLoader.cs +++ b/KustoSchemaTools/Parser/KustoLoader/KustoManagedIdentityPolicyLoader.cs @@ -1,41 +1,27 @@ using Kusto.Data.Common; using KustoSchemaTools.Model; using KustoSchemaTools.Plugins; -using Newtonsoft.Json; namespace KustoSchemaTools.Parser.KustoLoader { public class KustoManagedIdentityPolicyLoader : IKustoBulkEntitiesLoader { - const string script = @" -.show database policy managed_identity -| project Policies = parse_json(Policy) -| mv-expand Policy = Policies -| project ObjectId = tostring(Policy.ObjectId), AllowedUsages = tostring(Policy.AllowedUsages)"; + const string script = ".show database policy managed_identity | project Policy"; public async Task Load(Database database, string databaseName, KustoClient kusto) { var response = await kusto.Client.ExecuteQueryAsync(databaseName, script, new ClientRequestProperties()); - var rows = response.As(); + var rows = response.As(); + var policyJson = rows.FirstOrDefault()?.Policy ?? "[]"; + if (database.Policies == null) database.Policies = new DatabasePolicies(); - database.Policies.ManagedIdentity = rows - .Select(r => new ManagedIdentityPolicy - { - ObjectId = r.ObjectId, - AllowedUsages = r.AllowedUsages - .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) - .OrderBy(u => u) - .ToList() - }) - .OrderBy(p => p.ObjectId) - .ToList(); + database.Policies.ManagedIdentity = ManagedIdentityPolicy.ParseFromPolicyJson(policyJson); } - private class ManagedIdentityRow + private class ManagedIdentityRawRow { - public string ObjectId { get; set; } - public string AllowedUsages { get; set; } + public string Policy { get; set; } } } } From c521a47d0c0d131c01a43723208e8e3bfc91b8e5 Mon Sep 17 00:00:00 2001 From: Oleksandr Slynko Date: Tue, 14 Apr 2026 10:26:59 +0100 Subject: [PATCH 2/3] Move principal parsing from Kusto to C# Create PrincipalParser with testable static methods for role extraction, display name cleaning, and principal grouping. Simplify Kusto query to just project raw columns. Also loads Monitor role which was previously missing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Parser/PrincipalParserTests.cs | 124 ++++++++++++++++++ KustoSchemaTools/KustoSchemaTools.csproj | 4 + .../KustoDatabasePrincipalLoader.cs | 24 ++-- KustoSchemaTools/Parser/PrincipalParser.cs | 69 ++++++++++ 4 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 KustoSchemaTools.Tests/Parser/PrincipalParserTests.cs create mode 100644 KustoSchemaTools/Parser/PrincipalParser.cs diff --git a/KustoSchemaTools.Tests/Parser/PrincipalParserTests.cs b/KustoSchemaTools.Tests/Parser/PrincipalParserTests.cs new file mode 100644 index 0000000..84618a3 --- /dev/null +++ b/KustoSchemaTools.Tests/Parser/PrincipalParserTests.cs @@ -0,0 +1,124 @@ +using KustoSchemaTools.Parser; + +namespace KustoSchemaTools.Tests.Parser +{ + public class PrincipalParserTests + { + [Fact] + public void ParsePrincipals_BasicRoles_GroupsBySimplifiedRoleName() + { + var rows = new List + { + new() { Role = "Database Admin", PrincipalDisplayName = "Admin App", PrincipalFQN = "aadapp=aaa;tenant" }, + new() { Role = "Database User", PrincipalDisplayName = "User App", PrincipalFQN = "aadapp=bbb;tenant" }, + }; + + var result = PrincipalParser.ParsePrincipals(rows); + + Assert.True(result.ContainsKey("Admin")); + Assert.Single(result["Admin"]); + Assert.Equal("Admin App", result["Admin"][0].Name); + Assert.Equal("aadapp=aaa;tenant", result["Admin"][0].Id); + + Assert.True(result.ContainsKey("User")); + Assert.Single(result["User"]); + } + + [Fact] + public void ParsePrincipals_FiltersOutAllRoles() + { + var rows = new List + { + new() { Role = "Database Admin", PrincipalDisplayName = "Admin", PrincipalFQN = "aadapp=aaa;t" }, + new() { Role = "AllDatabasesAdmin", PrincipalDisplayName = "Cluster Admin", PrincipalFQN = "aadapp=bbb;t" }, + new() { Role = "AllDatabasesViewer", PrincipalDisplayName = "Cluster Viewer", PrincipalFQN = "aadgroup=ccc;t" }, + }; + + var result = PrincipalParser.ParsePrincipals(rows); + + Assert.Single(result); + Assert.True(result.ContainsKey("Admin")); + Assert.False(result.ContainsKey("AllDatabasesAdmin")); + } + + [Fact] + public void ParsePrincipals_MultipleAdmins_GroupedTogether() + { + var rows = new List + { + new() { Role = "Database Admin", PrincipalDisplayName = "App One", PrincipalFQN = "aadapp=111;t" }, + new() { Role = "Database Admin", PrincipalDisplayName = "App Two", PrincipalFQN = "aadapp=222;t" }, + }; + + var result = PrincipalParser.ParsePrincipals(rows); + + Assert.Equal(2, result["Admin"].Count); + } + + [Fact] + public void ParsePrincipals_NullInput_ReturnsEmptyDictionary() + { + var result = PrincipalParser.ParsePrincipals(null); + + Assert.Empty(result); + } + + [Fact] + public void ParsePrincipals_EmptyInput_ReturnsEmptyDictionary() + { + var result = PrincipalParser.ParsePrincipals(new List()); + + Assert.Empty(result); + } + + [Fact] + public void CleanDisplayName_RemovesParenthesizedSuffix() + { + Assert.Equal("My App", PrincipalParser.CleanDisplayName("My App (some-guid)")); + Assert.Equal("Simple Name", PrincipalParser.CleanDisplayName("Simple Name")); + Assert.Equal("", PrincipalParser.CleanDisplayName("")); + Assert.Equal("", PrincipalParser.CleanDisplayName(null)); + } + + [Fact] + public void CleanDisplayName_TrimsWhitespace() + { + Assert.Equal("My App", PrincipalParser.CleanDisplayName(" My App ")); + Assert.Equal("My App", PrincipalParser.CleanDisplayName("My App (guid) ")); + } + + [Fact] + public void ExtractRoleName_TakesLastWord() + { + Assert.Equal("Admin", PrincipalParser.ExtractRoleName("Database Admin")); + Assert.Equal("User", PrincipalParser.ExtractRoleName("Database User")); + Assert.Equal("UnrestrictedViewer", PrincipalParser.ExtractRoleName("Database UnrestrictedViewer")); + Assert.Equal("", PrincipalParser.ExtractRoleName("")); + Assert.Equal("", PrincipalParser.ExtractRoleName(null)); + } + + [Fact] + public void ParsePrincipals_AllKnownRoles_MappedCorrectly() + { + var rows = new List + { + new() { Role = "Database Admin", PrincipalDisplayName = "A1", PrincipalFQN = "aadapp=1;t" }, + new() { Role = "Database User", PrincipalDisplayName = "U1", PrincipalFQN = "aadapp=2;t" }, + new() { Role = "Database Viewer", PrincipalDisplayName = "V1", PrincipalFQN = "aadgroup=3;t" }, + new() { Role = "Database UnrestrictedViewer", PrincipalDisplayName = "UV1", PrincipalFQN = "aadapp=4;t" }, + new() { Role = "Database Ingestor", PrincipalDisplayName = "I1", PrincipalFQN = "aadapp=5;t" }, + new() { Role = "Database Monitor", PrincipalDisplayName = "M1", PrincipalFQN = "aadapp=6;t" }, + }; + + var result = PrincipalParser.ParsePrincipals(rows); + + Assert.Equal(6, result.Count); + Assert.True(result.ContainsKey("Admin")); + Assert.True(result.ContainsKey("User")); + Assert.True(result.ContainsKey("Viewer")); + Assert.True(result.ContainsKey("UnrestrictedViewer")); + Assert.True(result.ContainsKey("Ingestor")); + Assert.True(result.ContainsKey("Monitor")); + } + } +} diff --git a/KustoSchemaTools/KustoSchemaTools.csproj b/KustoSchemaTools/KustoSchemaTools.csproj index 7ff9f11..7ff0cb8 100644 --- a/KustoSchemaTools/KustoSchemaTools.csproj +++ b/KustoSchemaTools/KustoSchemaTools.csproj @@ -21,4 +21,8 @@ + + + + diff --git a/KustoSchemaTools/Parser/KustoLoader/KustoDatabasePrincipalLoader.cs b/KustoSchemaTools/Parser/KustoLoader/KustoDatabasePrincipalLoader.cs index 1d2c407..8b732ad 100644 --- a/KustoSchemaTools/Parser/KustoLoader/KustoDatabasePrincipalLoader.cs +++ b/KustoSchemaTools/Parser/KustoLoader/KustoDatabasePrincipalLoader.cs @@ -1,5 +1,4 @@ using Kusto.Data.Common; -using KustoSchemaTools.KustoTypes.DB; using KustoSchemaTools.Model; using KustoSchemaTools.Plugins; @@ -7,23 +6,20 @@ namespace KustoSchemaTools.Parser.KustoLoader { public class KustoDatabasePrincipalLoader : IKustoBulkEntitiesLoader { - const string script = @" -.show database principals -| project id=PrincipalFQN, name=trim(' ',tostring(split(PrincipalDisplayName,'(')[0])), Role=tostring(split(Role,' ')[-1]) -| where Role !startswith 'All' -| project AAObject=bag_pack('name',name,'id',id), Role -| summarize Users = make_list(AAObject) by Role -"; + const string script = ".show database principals | project Role, PrincipalDisplayName, PrincipalFQN"; public async Task Load(Database database, string databaseName, KustoClient kusto) { var response = await kusto.Client.ExecuteQueryAsync(databaseName, script, new ClientRequestProperties()); - var principals = response.As().ToDictionary(itm => itm.Role, itm => itm.Users); - database.Admins = principals.ContainsKey("Admin") ? principals["Admin"] : new List(); - database.Users = principals.ContainsKey("User") ? principals["User"] : new List(); - database.Ingestors = principals.ContainsKey("Ingestor") ? principals["Ingestor"] : new List(); - database.Viewers = principals.ContainsKey("Viewer") ? principals["Viewer"] : new List(); - database.UnrestrictedViewers = principals.ContainsKey("UnrestrictedViewer") ? principals["UnrestrictedViewer"] : new List(); + var rows = response.As(); + var principals = PrincipalParser.ParsePrincipals(rows); + + database.Admins = principals.GetValueOrDefault("Admin", new List()); + database.Users = principals.GetValueOrDefault("User", new List()); + database.Ingestors = principals.GetValueOrDefault("Ingestor", new List()); + database.Viewers = principals.GetValueOrDefault("Viewer", new List()); + database.UnrestrictedViewers = principals.GetValueOrDefault("UnrestrictedViewer", new List()); + database.Monitors = principals.GetValueOrDefault("Monitor", new List()); } } } diff --git a/KustoSchemaTools/Parser/PrincipalParser.cs b/KustoSchemaTools/Parser/PrincipalParser.cs new file mode 100644 index 0000000..e67030b --- /dev/null +++ b/KustoSchemaTools/Parser/PrincipalParser.cs @@ -0,0 +1,69 @@ +using KustoSchemaTools.Model; + +namespace KustoSchemaTools.Parser +{ + public static class PrincipalParser + { + /// + /// Parses raw rows from .show database principals into a dictionary + /// keyed by simplified role name (e.g., "Admin", "User"). + /// Filters out cluster-wide roles (those starting with "All"). + /// + public static Dictionary> ParsePrincipals(List rows) + { + if (rows == null) + return new Dictionary>(); + + return rows + .Select(r => new + { + Role = ExtractRoleName(r.Role), + Principal = new AADObject + { + Name = CleanDisplayName(r.PrincipalDisplayName), + Id = r.PrincipalFQN + } + }) + .Where(r => !r.Role.StartsWith("All")) + .GroupBy(r => r.Role) + .ToDictionary( + g => g.Key, + g => g.Select(r => r.Principal).ToList() + ); + } + + /// + /// Extracts the last word from a role string. + /// E.g., "Database Admin" → "Admin", "Database User" → "User". + /// + internal static string ExtractRoleName(string role) + { + if (string.IsNullOrWhiteSpace(role)) + return ""; + + var parts = role.Split(' '); + return parts[^1]; + } + + /// + /// Cleans a display name by removing the parenthesized suffix and trimming. + /// E.g., "My App (some-guid)" → "My App". + /// + internal static string CleanDisplayName(string displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) + return ""; + + var parenIndex = displayName.IndexOf('('); + var name = parenIndex >= 0 ? displayName[..parenIndex] : displayName; + return name.Trim(); + } + } + + public class PrincipalRawRow + { + public string Role { get; set; } + public string PrincipalDisplayName { get; set; } + public string PrincipalFQN { get; set; } + } +} From d07ffec01b381393754e2ff890e684bf43b6f1f4 Mon Sep 17 00:00:00 2001 From: Oleksandr Slynko Date: Tue, 14 Apr 2026 10:33:29 +0100 Subject: [PATCH 3/3] Normalize managed identity principal IDs after cluster load Replace ClientId with ObjectId in principal FQNs using exact FQN parsing (only aadapp kind, case-insensitive). Handles duplicate ClientId entries gracefully via GroupBy. Called in KustoDatabaseHandler.LoadAsync() after all loaders complete. Fixes phantom permission diffs when .show database principals returns ClientId but YAML stores ObjectId. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Changes/PrincipalNormalizationTests.cs | 347 ++++++++++++++++++ KustoSchemaTools/Model/Database.cs | 72 ++++ .../Parser/KustoDatabaseHandler.cs | 1 + 3 files changed, 420 insertions(+) create mode 100644 KustoSchemaTools.Tests/Changes/PrincipalNormalizationTests.cs diff --git a/KustoSchemaTools.Tests/Changes/PrincipalNormalizationTests.cs b/KustoSchemaTools.Tests/Changes/PrincipalNormalizationTests.cs new file mode 100644 index 0000000..86708d4 --- /dev/null +++ b/KustoSchemaTools.Tests/Changes/PrincipalNormalizationTests.cs @@ -0,0 +1,347 @@ +using KustoSchemaTools.Model; +using KustoSchemaTools.Changes; +using Microsoft.Extensions.Logging; +using Moq; + +namespace KustoSchemaTools.Tests.Changes +{ + public class PrincipalNormalizationTests + { + private const string ObjectId = "8749feae-888c-446b-9f38-26f0c38ba1cd"; + private const string ClientId = "1de2a36c-bba4-4380-be8d-5f400303219b"; + private const string TenantId = "398a6654-997b-47e9-b12b-9515b896b4de"; + + [Fact] + public void NormalizePrincipalIds_ReplacesClientIdWithObjectId() + { + var db = new Database + { + Policies = new DatabasePolicies + { + ManagedIdentity = new List + { + new() { ObjectId = ObjectId, ClientId = ClientId } + } + }, + Admins = new List + { + new() { Name = "my-identity", Id = $"aadapp={ClientId};{TenantId}" } + } + }; + + db.NormalizePrincipalIds(); + + Assert.Equal($"aadapp={ObjectId};{TenantId}", db.Admins[0].Id); + } + + [Fact] + public void NormalizePrincipalIds_DoesNotChangeUnrelatedPrincipals() + { + var unrelatedId = "aadapp=99999999-9999-9999-9999-999999999999;tenant"; + var db = new Database + { + Policies = new DatabasePolicies + { + ManagedIdentity = new List + { + new() { ObjectId = ObjectId, ClientId = ClientId } + } + }, + Admins = new List + { + new() { Name = "other-app", Id = unrelatedId } + } + }; + + db.NormalizePrincipalIds(); + + Assert.Equal(unrelatedId, db.Admins[0].Id); + } + + [Fact] + public void NormalizePrincipalIds_NormalizesAllRoleLists() + { + var db = new Database + { + Policies = new DatabasePolicies + { + ManagedIdentity = new List + { + new() { ObjectId = ObjectId, ClientId = ClientId } + } + }, + Admins = new List { new() { Name = "a", Id = $"aadapp={ClientId};t" } }, + Users = new List { new() { Name = "u", Id = $"aadapp={ClientId};t" } }, + Viewers = new List { new() { Name = "v", Id = $"aadapp={ClientId};t" } }, + UnrestrictedViewers = new List { new() { Name = "uv", Id = $"aadapp={ClientId};t" } }, + Ingestors = new List { new() { Name = "i", Id = $"aadapp={ClientId};t" } }, + Monitors = new List { new() { Name = "m", Id = $"aadapp={ClientId};t" } }, + }; + + db.NormalizePrincipalIds(); + + Assert.All(new[] { db.Admins, db.Users, db.Viewers, db.UnrestrictedViewers, db.Ingestors, db.Monitors }, + list => Assert.Contains(ObjectId, list[0].Id)); + } + + [Fact] + public void NormalizePrincipalIds_NoManagedIdentity_NoOp() + { + var originalId = $"aadapp={ClientId};tenant"; + var db = new Database + { + Admins = new List + { + new() { Name = "app", Id = originalId } + } + }; + + db.NormalizePrincipalIds(); + + Assert.Equal(originalId, db.Admins[0].Id); + } + + [Fact] + public void NormalizePrincipalIds_NullClientId_SkipsPolicy() + { + var originalId = $"aadapp={ClientId};tenant"; + var db = new Database + { + Policies = new DatabasePolicies + { + ManagedIdentity = new List + { + new() { ObjectId = ObjectId, ClientId = null } + } + }, + Admins = new List + { + new() { Name = "app", Id = originalId } + } + }; + + db.NormalizePrincipalIds(); + + Assert.Equal(originalId, db.Admins[0].Id); + } + + [Fact] + public void NormalizePrincipalIds_SameClientAndObjectId_SkipsPolicy() + { + var originalId = $"aadapp={ObjectId};tenant"; + var db = new Database + { + Policies = new DatabasePolicies + { + ManagedIdentity = new List + { + new() { ObjectId = ObjectId, ClientId = ObjectId } + } + }, + Admins = new List + { + new() { Name = "app", Id = originalId } + } + }; + + db.NormalizePrincipalIds(); + + Assert.Equal(originalId, db.Admins[0].Id); + } + + [Fact] + public void PermissionChange_NoPhantomDiff_AfterNormalization() + { + // Simulate: YAML has ObjectId, cluster loaded with ClientId then normalized + var yamlAdmins = new List + { + new() { Name = "regular-app", Id = $"aadapp=aaaa;{TenantId}" }, + new() { Name = "my-identity", Id = $"aadapp={ObjectId};{TenantId}" }, + }; + + // Cluster originally returned ClientId, but after normalization it has ObjectId + var clusterAdmins = new List + { + new() { Name = "regular-app", Id = $"aadapp=aaaa;{TenantId}" }, + new() { Name = "my-identity", Id = $"aadapp={ObjectId};{TenantId}" }, + }; + + var change = new PermissionChange("testdb", "Admins", clusterAdmins, yamlAdmins); + + // No scripts should be generated since the lists are identical after normalization + Assert.Empty(change.Scripts); + } + + [Fact] + public void PermissionChange_DetectsRealDiff_WithNormalization() + { + // YAML adds a new admin that wasn't in the cluster + var yamlAdmins = new List + { + new() { Name = "existing-app", Id = $"aadapp=aaaa;{TenantId}" }, + new() { Name = "new-app", Id = $"aadapp=bbbb;{TenantId}" }, + }; + + var clusterAdmins = new List + { + new() { Name = "existing-app", Id = $"aadapp=aaaa;{TenantId}" }, + }; + + var change = new PermissionChange("testdb", "Admins", clusterAdmins, yamlAdmins); + + // Should generate a script because there's a real difference + Assert.NotEmpty(change.Scripts); + } + + [Fact] + public void PermissionChange_PhantomDiff_WithoutNormalization() + { + // Without normalization: YAML has ObjectId, cluster has ClientId → phantom diff + var yamlAdmins = new List + { + new() { Name = "my-identity", Id = $"aadapp={ObjectId};{TenantId}" }, + }; + + var clusterAdmins = new List + { + new() { Name = "my-identity", Id = $"aadapp={ClientId};{TenantId}" }, + }; + + var change = new PermissionChange("testdb", "Admins", clusterAdmins, yamlAdmins); + + // Without normalization, this would incorrectly generate a script + Assert.NotEmpty(change.Scripts); + } + + [Fact] + public void NormalizePrincipalIds_MultipleManagedIdentities_AllNormalized() + { + var objectId2 = "aaaa-bbbb-cccc"; + var clientId2 = "dddd-eeee-ffff"; + + var db = new Database + { + Policies = new DatabasePolicies + { + ManagedIdentity = new List + { + new() { ObjectId = ObjectId, ClientId = ClientId }, + new() { ObjectId = objectId2, ClientId = clientId2 }, + } + }, + Admins = new List + { + new() { Name = "id1", Id = $"aadapp={ClientId};t" }, + new() { Name = "id2", Id = $"aadapp={clientId2};t" }, + } + }; + + db.NormalizePrincipalIds(); + + Assert.Equal($"aadapp={ObjectId};t", db.Admins[0].Id); + Assert.Equal($"aadapp={objectId2};t", db.Admins[1].Id); + } + + [Fact] + public void ParseFqn_StandardFormat() + { + var (kind, guid, rest) = Database.ParseFqn("aadapp=8749feae-888c-446b-9f38-26f0c38ba1cd;398a6654-997b-47e9-b12b-9515b896b4de"); + + Assert.Equal("aadapp", kind); + Assert.Equal("8749feae-888c-446b-9f38-26f0c38ba1cd", guid); + Assert.Equal(";398a6654-997b-47e9-b12b-9515b896b4de", rest); + } + + [Fact] + public void ParseFqn_NoSemicolon() + { + var (kind, guid, rest) = Database.ParseFqn("aadapp=someguid"); + + Assert.Equal("aadapp", kind); + Assert.Equal("someguid", guid); + Assert.Equal("", rest); + } + + [Fact] + public void ParseFqn_NoEquals_ReturnsNulls() + { + var (kind, guid, rest) = Database.ParseFqn("invalidformat"); + + Assert.Null(kind); + Assert.Null(guid); + } + + [Fact] + public void NormalizePrincipalIds_OnlyNormalizesAadappKind() + { + var db = new Database + { + Policies = new DatabasePolicies + { + ManagedIdentity = new List + { + new() { ObjectId = ObjectId, ClientId = ClientId } + } + }, + Admins = new List + { + new() { Name = "group", Id = $"aadgroup={ClientId};{TenantId}" } + } + }; + + db.NormalizePrincipalIds(); + + // aadgroup should NOT be normalized, only aadapp + Assert.Equal($"aadgroup={ClientId};{TenantId}", db.Admins[0].Id); + } + + [Fact] + public void NormalizePrincipalIds_CaseInsensitiveClientIdMatch() + { + var upperClientId = ClientId.ToUpperInvariant(); + var db = new Database + { + Policies = new DatabasePolicies + { + ManagedIdentity = new List + { + new() { ObjectId = ObjectId, ClientId = ClientId } + } + }, + Admins = new List + { + new() { Name = "my-identity", Id = $"aadapp={upperClientId};{TenantId}" } + } + }; + + db.NormalizePrincipalIds(); + + Assert.Equal($"aadapp={ObjectId};{TenantId}", db.Admins[0].Id); + } + + [Fact] + public void NormalizePrincipalIds_DuplicateClientIds_HandledGracefully() + { + var db = new Database + { + Policies = new DatabasePolicies + { + ManagedIdentity = new List + { + new() { ObjectId = ObjectId, ClientId = ClientId }, + new() { ObjectId = ObjectId, ClientId = ClientId }, // duplicate + } + }, + Admins = new List + { + new() { Name = "my-identity", Id = $"aadapp={ClientId};{TenantId}" } + } + }; + + // Should not throw + db.NormalizePrincipalIds(); + + Assert.Equal($"aadapp={ObjectId};{TenantId}", db.Admins[0].Id); + } + } +} diff --git a/KustoSchemaTools/Model/Database.cs b/KustoSchemaTools/Model/Database.cs index 84eacad..b906929 100644 --- a/KustoSchemaTools/Model/Database.cs +++ b/KustoSchemaTools/Model/Database.cs @@ -40,5 +40,77 @@ public class Database public DatabasePolicies Policies { get; set; } = new DatabasePolicies(); public string EscapedName => Name.BracketIfIdentifier(); + + /// + /// Replaces managed identity ClientId with ObjectId in all principal FQNs. + /// This prevents phantom diffs when .show database principals returns + /// ClientId but the YAML stores ObjectId. + /// + public void NormalizePrincipalIds() + { + var clientToObject = BuildClientToObjectIdMap(); + if (clientToObject == null || !clientToObject.Any()) + return; + + NormalizePrincipalList(Admins, clientToObject); + NormalizePrincipalList(Users, clientToObject); + NormalizePrincipalList(Viewers, clientToObject); + NormalizePrincipalList(UnrestrictedViewers, clientToObject); + NormalizePrincipalList(Ingestors, clientToObject); + NormalizePrincipalList(Monitors, clientToObject); + } + + internal Dictionary BuildClientToObjectIdMap() + { + if (Policies?.ManagedIdentity == null) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + return Policies.ManagedIdentity + .Where(p => !string.IsNullOrEmpty(p.ClientId) && !string.IsNullOrEmpty(p.ObjectId) && !string.Equals(p.ClientId, p.ObjectId, StringComparison.OrdinalIgnoreCase)) + .GroupBy(p => p.ClientId, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => g.First().ObjectId, + StringComparer.OrdinalIgnoreCase); + } + + private static void NormalizePrincipalList(List principals, Dictionary clientToObject) + { + if (principals == null) return; + + foreach (var principal in principals) + { + if (string.IsNullOrEmpty(principal.Id)) continue; + + var (kind, guid, rest) = ParseFqn(principal.Id); + if (kind == null || guid == null) continue; + + if (kind.Equals("aadapp", StringComparison.OrdinalIgnoreCase) + && clientToObject.TryGetValue(guid, out var objectId)) + { + principal.Id = $"{kind}={objectId}{rest}"; + } + } + } + + /// + /// Parses a PrincipalFQN like "aadapp=guid;tenant" into (kind, guid, rest). + /// rest includes the semicolon and tenant portion. + /// + internal static (string kind, string guid, string rest) ParseFqn(string fqn) + { + var eqIndex = fqn.IndexOf('='); + if (eqIndex < 0) return (null, null, null); + + var kind = fqn[..eqIndex]; + var afterEq = fqn[(eqIndex + 1)..]; + + var semiIndex = afterEq.IndexOf(';'); + if (semiIndex < 0) return (kind, afterEq, ""); + + var guid = afterEq[..semiIndex]; + var rest = afterEq[semiIndex..]; + return (kind, guid, rest); + } } } diff --git a/KustoSchemaTools/Parser/KustoDatabaseHandler.cs b/KustoSchemaTools/Parser/KustoDatabaseHandler.cs index f563178..cf6946f 100644 --- a/KustoSchemaTools/Parser/KustoDatabaseHandler.cs +++ b/KustoSchemaTools/Parser/KustoDatabaseHandler.cs @@ -31,6 +31,7 @@ public async Task LoadAsync() await plugin.Load(database, DatabaseName, Client); } + database.NormalizePrincipalIds(); return database; } public async Task WriteAsync(T database)