diff --git a/KustoSchemaTools.Tests/DemoData/DemoDeployment/DemoDatabase/database.yml b/KustoSchemaTools.Tests/DemoData/DemoDeployment/DemoDatabase/database.yml index c669ddc..cfa2ba2 100644 --- a/KustoSchemaTools.Tests/DemoData/DemoDeployment/DemoDatabase/database.yml +++ b/KustoSchemaTools.Tests/DemoData/DemoDeployment/DemoDatabase/database.yml @@ -13,6 +13,13 @@ admins: - name: SPN-ADMIN id: aadapp=f678ce29-8f92-4d6e-b95d-f2ed8fa7713f;7396cfeb-2920-488f-b0bb-81a584d34a24 +policies: + managedIdentity: + - objectId: 12345678-1234-1234-1234-123456789abc + allowedUsages: + - NativeIngestion + - ExternalTable + tables: sourceTable: restrictedViewAccess: true diff --git a/KustoSchemaTools.Tests/Model/ManagedIdentityPolicyTests.cs b/KustoSchemaTools.Tests/Model/ManagedIdentityPolicyTests.cs new file mode 100644 index 0000000..66e6ff0 --- /dev/null +++ b/KustoSchemaTools.Tests/Model/ManagedIdentityPolicyTests.cs @@ -0,0 +1,243 @@ +using KustoSchemaTools.Model; +using KustoSchemaTools.Changes; +using Microsoft.Extensions.Logging; +using Moq; + +namespace KustoSchemaTools.Tests.ManagedIdentity +{ + public class ManagedIdentityPolicyTests + { + [Fact] + public void CreateCombinedScript_SinglePolicy_GeneratesCorrectKql() + { + // Arrange + var policies = new List + { + new ManagedIdentityPolicy + { + ObjectId = "12345678-1234-1234-1234-123456789abc", + AllowedUsages = new List { "NativeIngestion" } + } + }; + + // Act + var script = ManagedIdentityPolicy.CreateCombinedScript("MyDatabase", policies); + + // Assert + Assert.Equal("ManagedIdentityPolicy", script.Kind); + Assert.Equal(80, script.Script.Order); + Assert.Contains(".alter-merge database MyDatabase policy managed_identity", script.Script.Text); + Assert.Contains("\"ObjectId\": \"12345678-1234-1234-1234-123456789abc\"", script.Script.Text); + Assert.Contains("\"AllowedUsages\": \"NativeIngestion\"", script.Script.Text); + } + + [Fact] + public void CreateCombinedScript_MultipleUsages_JoinsWithCommaAlphabetically() + { + // Arrange + var policies = new List + { + new ManagedIdentityPolicy + { + ObjectId = "12345678-1234-1234-1234-123456789abc", + AllowedUsages = new List { "NativeIngestion", "AutomatedFlows", "ExternalTable" } + } + }; + + // Act + var script = ManagedIdentityPolicy.CreateCombinedScript("MyDatabase", policies); + + // Assert + Assert.Contains("\"AllowedUsages\": \"AutomatedFlows, ExternalTable, NativeIngestion\"", script.Script.Text); + } + + [Fact] + public void CreateCombinedScript_MultiplePolicies_SortsByObjectId() + { + // Arrange + var policies = new List + { + new ManagedIdentityPolicy + { + ObjectId = "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz", + AllowedUsages = new List { "ExternalTable" } + }, + new ManagedIdentityPolicy + { + ObjectId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + AllowedUsages = new List { "NativeIngestion" } + } + }; + + // Act + var script = ManagedIdentityPolicy.CreateCombinedScript("MyDatabase", policies); + + // Assert - both identities in a single script, sorted by ObjectId + Assert.Equal("ManagedIdentityPolicy", script.Kind); + var aIdx = script.Script.Text.IndexOf("aaaaaaaa"); + var zIdx = script.Script.Text.IndexOf("zzzzzzzz"); + Assert.True(aIdx < zIdx, "Policies should be sorted by ObjectId"); + } + + [Fact] + public void CreateCombinedScript_DatabaseNameUsedInKql() + { + // Arrange + var policies = new List + { + new ManagedIdentityPolicy + { + ObjectId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + AllowedUsages = new List { "ExternalTable" } + } + }; + + // Act + var script = ManagedIdentityPolicy.CreateCombinedScript("TargetDatabase", policies); + + // Assert + Assert.StartsWith(".alter-merge database TargetDatabase policy managed_identity", script.Script.Text); + } + + [Fact] + public void CreateCombinedScript_WrapsJsonInBackticks() + { + // Arrange + var policies = new List + { + new ManagedIdentityPolicy + { + ObjectId = "12345678-1234-1234-1234-123456789abc", + AllowedUsages = new List { "NativeIngestion" } + } + }; + + // Act + var script = ManagedIdentityPolicy.CreateCombinedScript("MyDatabase", policies); + + // Assert + Assert.Contains("```", script.Script.Text); + Assert.EndsWith("```", script.Script.Text); + } + + [Fact] + public void DatabaseChanges_WithManagedIdentityPolicies_GeneratesScript() + { + // Arrange + var loggerMock = new Mock(); + var oldState = new Database { Name = "TestDb" }; + var newState = new Database + { + Name = "TestDb", + Policies = new DatabasePolicies + { + ManagedIdentity = new List + { + new ManagedIdentityPolicy + { + ObjectId = "12345678-1234-1234-1234-123456789abc", + AllowedUsages = new List { "NativeIngestion" } + } + } + } + }; + + // Act + var changes = DatabaseChanges.GenerateChanges(oldState, newState, "TestDb", loggerMock.Object); + + // Assert + Assert.NotEmpty(changes); + var scripts = changes.SelectMany(c => c.Scripts).ToList(); + Assert.NotEmpty(scripts); + var managedIdentityScript = scripts.FirstOrDefault(s => s.Kind == "ManagedIdentityPolicy"); + Assert.NotNull(managedIdentityScript); + Assert.Contains(".alter-merge database TestDb policy managed_identity", managedIdentityScript.Script.Text); + Assert.Contains("12345678-1234-1234-1234-123456789abc", managedIdentityScript.Script.Text); + } + + [Fact] + public void DatabaseChanges_WithMultipleManagedIdentityPolicies_GeneratesSingleScript() + { + // Arrange + var loggerMock = new Mock(); + var oldState = new Database { Name = "TestDb" }; + var newState = new Database + { + Name = "TestDb", + Policies = new DatabasePolicies + { + ManagedIdentity = new List + { + new ManagedIdentityPolicy + { + ObjectId = "aaaaaaaa-1111-2222-3333-444444444444", + AllowedUsages = new List { "NativeIngestion" } + }, + new ManagedIdentityPolicy + { + ObjectId = "bbbbbbbb-1111-2222-3333-444444444444", + AllowedUsages = new List { "ExternalTable" } + } + } + } + }; + + // Act + var changes = DatabaseChanges.GenerateChanges(oldState, newState, "TestDb", loggerMock.Object); + + // Assert - should generate exactly one ManagedIdentityPolicy script (combined) + var managedIdentityScripts = changes + .SelectMany(c => c.Scripts) + .Where(s => s.Kind == "ManagedIdentityPolicy") + .ToList(); + Assert.Single(managedIdentityScripts); + Assert.Contains("aaaaaaaa-1111-2222-3333-444444444444", managedIdentityScripts[0].Script.Text); + Assert.Contains("bbbbbbbb-1111-2222-3333-444444444444", managedIdentityScripts[0].Script.Text); + } + + [Fact] + public void DatabaseChanges_WithUnchangedManagedIdentityPolicies_GeneratesNoChanges() + { + // Arrange + var loggerMock = new Mock(); + var policy = new ManagedIdentityPolicy + { + ObjectId = "12345678-1234-1234-1234-123456789abc", + AllowedUsages = new List { "NativeIngestion" } + }; + var oldState = new Database + { + Name = "TestDb", + Policies = new DatabasePolicies + { + ManagedIdentity = new List { policy } + } + }; + var newState = new Database + { + Name = "TestDb", + Policies = new DatabasePolicies + { + ManagedIdentity = new List + { + new ManagedIdentityPolicy + { + ObjectId = "12345678-1234-1234-1234-123456789abc", + AllowedUsages = new List { "NativeIngestion" } + } + } + } + }; + + // Act + var changes = DatabaseChanges.GenerateChanges(oldState, newState, "TestDb", loggerMock.Object); + + // Assert - no database-level changes since policies are identical + var databaseScriptChanges = changes + .SelectMany(c => c.Scripts) + .Where(s => s.Kind == "ManagedIdentityPolicy") + .ToList(); + Assert.Empty(databaseScriptChanges); + } + } +} diff --git a/KustoSchemaTools.Tests/Parser/YamlDatabaseHandlerTests.cs b/KustoSchemaTools.Tests/Parser/YamlDatabaseHandlerTests.cs index c5c4b71..ce00444 100644 --- a/KustoSchemaTools.Tests/Parser/YamlDatabaseHandlerTests.cs +++ b/KustoSchemaTools.Tests/Parser/YamlDatabaseHandlerTests.cs @@ -41,6 +41,16 @@ public async Task GetDatabase() Assert.NotNull(tt.Policies); Assert.False(tt.Policies!.RestrictedViewAccess); Assert.Equal("120d", tt.Policies?.Retention); + + // Verify managed identity policies are loaded from database.yml + Assert.NotNull(db.Policies); + Assert.NotNull(db.Policies.ManagedIdentity); + Assert.Single(db.Policies.ManagedIdentity); + var miPolicy = db.Policies.ManagedIdentity[0]; + Assert.Equal("12345678-1234-1234-1234-123456789abc", miPolicy.ObjectId); + Assert.Equal(2, miPolicy.AllowedUsages.Count); + Assert.Contains("NativeIngestion", miPolicy.AllowedUsages); + Assert.Contains("ExternalTable", miPolicy.AllowedUsages); } [Fact] diff --git a/KustoSchemaTools/Changes/DatabaseChanges.cs b/KustoSchemaTools/Changes/DatabaseChanges.cs index 17f3a5a..0770927 100644 --- a/KustoSchemaTools/Changes/DatabaseChanges.cs +++ b/KustoSchemaTools/Changes/DatabaseChanges.cs @@ -22,6 +22,8 @@ public static List GenerateChanges(Database oldState, Database newState otherFromScripts.AddRange(oldState.Scripts.Select(itm => new DatabaseScriptContainer(itm, "DatabaseScript"))); if (oldState.DefaultRetentionAndCache != null) otherFromScripts.AddRange(oldState.DefaultRetentionAndCache.CreateScripts(name, "database")); + if (oldState.Policies?.ManagedIdentity != null && oldState.Policies.ManagedIdentity.Any()) + otherFromScripts.Add(ManagedIdentityPolicy.CreateCombinedScript(name, oldState.Policies.ManagedIdentity)); } var otherToScripts = new List(); @@ -29,6 +31,8 @@ public static List GenerateChanges(Database oldState, Database newState otherToScripts.AddRange(newState.Scripts.Select(itm => new DatabaseScriptContainer(itm, "DatabaseScript"))); if (newState.DefaultRetentionAndCache != null) otherToScripts.AddRange(newState.DefaultRetentionAndCache.CreateScripts(name, "database")); + if (newState.Policies?.ManagedIdentity != null && newState.Policies.ManagedIdentity.Any()) + otherToScripts.Add(ManagedIdentityPolicy.CreateCombinedScript(name, newState.Policies.ManagedIdentity)); if (otherToScripts.Count > 0) { diff --git a/KustoSchemaTools/Model/Database.cs b/KustoSchemaTools/Model/Database.cs index e19340f..84eacad 100644 --- a/KustoSchemaTools/Model/Database.cs +++ b/KustoSchemaTools/Model/Database.cs @@ -37,6 +37,8 @@ public class Database public Dictionary Followers { get; set; } = new Dictionary(); + public DatabasePolicies Policies { get; set; } = new DatabasePolicies(); + public string EscapedName => Name.BracketIfIdentifier(); } } diff --git a/KustoSchemaTools/Model/DatabasePolicies.cs b/KustoSchemaTools/Model/DatabasePolicies.cs new file mode 100644 index 0000000..d6144d7 --- /dev/null +++ b/KustoSchemaTools/Model/DatabasePolicies.cs @@ -0,0 +1,7 @@ +namespace KustoSchemaTools.Model +{ + public class DatabasePolicies + { + public List ManagedIdentity { get; set; } = new List(); + } +} diff --git a/KustoSchemaTools/Model/ManagedIdentityPolicy.cs b/KustoSchemaTools/Model/ManagedIdentityPolicy.cs new file mode 100644 index 0000000..cd5493e --- /dev/null +++ b/KustoSchemaTools/Model/ManagedIdentityPolicy.cs @@ -0,0 +1,27 @@ +using KustoSchemaTools.Changes; +using Newtonsoft.Json; +using KustoSchemaTools.Helpers; +using KustoSchemaTools.Parser; + +namespace KustoSchemaTools.Model +{ + public class ManagedIdentityPolicy + { + public string ObjectId { get; set; } + public List AllowedUsages { get; set; } = new List(); + + /// + /// 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. + /// + public static DatabaseScriptContainer CreateCombinedScript(string databaseName, List policies) + { + var policyObjects = policies + .OrderBy(p => p.ObjectId) + .Select(p => new { p.ObjectId, AllowedUsages = string.Join(", ", p.AllowedUsages.OrderBy(u => u)) }) + .ToArray(); + var json = JsonConvert.SerializeObject(policyObjects, Serialization.JsonPascalCase); + return new DatabaseScriptContainer("ManagedIdentityPolicy", 80, $".alter-merge database {databaseName.BracketIfIdentifier()} policy managed_identity ```{json}```"); + } + } +} diff --git a/KustoSchemaTools/Parser/KustoLoader/KustoManagedIdentityPolicyLoader.cs b/KustoSchemaTools/Parser/KustoLoader/KustoManagedIdentityPolicyLoader.cs new file mode 100644 index 0000000..bfbb019 --- /dev/null +++ b/KustoSchemaTools/Parser/KustoLoader/KustoManagedIdentityPolicyLoader.cs @@ -0,0 +1,41 @@ +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)"; + + public async Task Load(Database database, string databaseName, KustoClient kusto) + { + var response = await kusto.Client.ExecuteQueryAsync(databaseName, script, new ClientRequestProperties()); + var rows = response.As(); + 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(); + } + + private class ManagedIdentityRow + { + public string ObjectId { get; set; } + public string AllowedUsages { get; set; } + } + } +}