Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
243 changes: 243 additions & 0 deletions KustoSchemaTools.Tests/Model/ManagedIdentityPolicyTests.cs
Original file line number Diff line number Diff line change
@@ -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<ManagedIdentityPolicy>
{
new ManagedIdentityPolicy
{
ObjectId = "12345678-1234-1234-1234-123456789abc",
AllowedUsages = new List<string> { "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<ManagedIdentityPolicy>
{
new ManagedIdentityPolicy
{
ObjectId = "12345678-1234-1234-1234-123456789abc",
AllowedUsages = new List<string> { "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<ManagedIdentityPolicy>
{
new ManagedIdentityPolicy
{
ObjectId = "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
AllowedUsages = new List<string> { "ExternalTable" }
},
new ManagedIdentityPolicy
{
ObjectId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
AllowedUsages = new List<string> { "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<ManagedIdentityPolicy>
{
new ManagedIdentityPolicy
{
ObjectId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
AllowedUsages = new List<string> { "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<ManagedIdentityPolicy>
{
new ManagedIdentityPolicy
{
ObjectId = "12345678-1234-1234-1234-123456789abc",
AllowedUsages = new List<string> { "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<ILogger>();
var oldState = new Database { Name = "TestDb" };
var newState = new Database
{
Name = "TestDb",
Policies = new DatabasePolicies
{
ManagedIdentity = new List<ManagedIdentityPolicy>
{
new ManagedIdentityPolicy
{
ObjectId = "12345678-1234-1234-1234-123456789abc",
AllowedUsages = new List<string> { "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<ILogger>();
var oldState = new Database { Name = "TestDb" };
var newState = new Database
{
Name = "TestDb",
Policies = new DatabasePolicies
{
ManagedIdentity = new List<ManagedIdentityPolicy>
{
new ManagedIdentityPolicy
{
ObjectId = "aaaaaaaa-1111-2222-3333-444444444444",
AllowedUsages = new List<string> { "NativeIngestion" }
},
new ManagedIdentityPolicy
{
ObjectId = "bbbbbbbb-1111-2222-3333-444444444444",
AllowedUsages = new List<string> { "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<ILogger>();
var policy = new ManagedIdentityPolicy
{
ObjectId = "12345678-1234-1234-1234-123456789abc",
AllowedUsages = new List<string> { "NativeIngestion" }
};
var oldState = new Database
{
Name = "TestDb",
Policies = new DatabasePolicies
{
ManagedIdentity = new List<ManagedIdentityPolicy> { policy }
}
};
var newState = new Database
{
Name = "TestDb",
Policies = new DatabasePolicies
{
ManagedIdentity = new List<ManagedIdentityPolicy>
{
new ManagedIdentityPolicy
{
ObjectId = "12345678-1234-1234-1234-123456789abc",
AllowedUsages = new List<string> { "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);
}
}
}
10 changes: 10 additions & 0 deletions KustoSchemaTools.Tests/Parser/YamlDatabaseHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions KustoSchemaTools/Changes/DatabaseChanges.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@ public static List<IChange> 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<DatabaseScriptContainer>();
if (newState.Scripts != null)
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)
{
Expand Down
2 changes: 2 additions & 0 deletions KustoSchemaTools/Model/Database.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
{
public string Name { get; set; }
public string Team { get; set; } = "";
public RetentionAndCachePolicy DefaultRetentionAndCache { get; set; } = new RetentionAndCachePolicy();

Check warning on line 11 in KustoSchemaTools/Model/Database.cs

View workflow job for this annotation

GitHub Actions / build

'RetentionAndCachePolicy' is obsolete: 'Use policies instead'

public List<AADObject> Monitors { get; set; } = new List<AADObject>();

Expand Down Expand Up @@ -37,6 +37,8 @@

public Dictionary<string, FollowerDatabase> Followers { get; set; } = new Dictionary<string, FollowerDatabase>();

public DatabasePolicies Policies { get; set; } = new DatabasePolicies();

public string EscapedName => Name.BracketIfIdentifier();
}
}
7 changes: 7 additions & 0 deletions KustoSchemaTools/Model/DatabasePolicies.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace KustoSchemaTools.Model
{
public class DatabasePolicies
{
public List<ManagedIdentityPolicy> ManagedIdentity { get; set; } = new List<ManagedIdentityPolicy>();
}
}
27 changes: 27 additions & 0 deletions KustoSchemaTools/Model/ManagedIdentityPolicy.cs
Original file line number Diff line number Diff line change
@@ -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<string> AllowedUsages { get; set; } = new List<string>();

/// <summary>
/// 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.
/// </summary>
public static DatabaseScriptContainer CreateCombinedScript(string databaseName, List<ManagedIdentityPolicy> 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}```");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Kusto.Data.Common;
using KustoSchemaTools.Model;
using KustoSchemaTools.Plugins;
using Newtonsoft.Json;
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the unused using Newtonsoft.Json; directive (it’s not referenced in this loader). Keeping it adds noise and may introduce build warnings if the repo ever enables warnings-as-errors.

Suggested change
using Newtonsoft.Json;

Copilot uses AI. Check for mistakes.

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<ManagedIdentityRow>();
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; }
}
}
}
Loading