Skip to content

Commit b6d22fc

Browse files
committed
Use follower add/drop for permissions and quote follower metadata
1 parent 793d8bd commit b6d22fc

File tree

6 files changed

+276
-5
lines changed

6 files changed

+276
-5
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using KustoSchemaTools.Changes;
2+
using KustoSchemaTools.Model;
3+
using Microsoft.Extensions.Logging;
4+
using Moq;
5+
6+
namespace KustoSchemaTools.Tests.Changes
7+
{
8+
public class FollowerPermissionChangeTests
9+
{
10+
private readonly Mock<ILogger> _logger = new();
11+
12+
private static FollowerDatabase BuildFollower(params (string role, string id, string name)[] principals)
13+
{
14+
var follower = new FollowerDatabase
15+
{
16+
DatabaseName = "DDoSNeuralAnalysis",
17+
Permissions = new FollowerPermissions { ModificationKind = FollowerModificationKind.Union }
18+
};
19+
20+
foreach (var (role, id, name) in principals)
21+
{
22+
var obj = new AADObject { Id = id, Name = name };
23+
if (string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase))
24+
{
25+
follower.Permissions.Admins.Add(obj);
26+
}
27+
else
28+
{
29+
follower.Permissions.Viewers.Add(obj);
30+
}
31+
}
32+
33+
return follower;
34+
}
35+
36+
[Fact]
37+
public void GeneratesFollowerAdd_WithLeaderName()
38+
{
39+
var oldFollower = BuildFollower();
40+
var newFollower = BuildFollower(("viewer", "aadapp=64decea3-723a-4fbf-b2ec-9faaf852cfdc;398a6654-997b-47e9-b12b-9515b896b4de", "spn-dev-spam-slam"));
41+
newFollower.Permissions.LeaderName = "leader-cluster";
42+
43+
var changes = DatabaseChanges.GenerateFollowerChanges(oldFollower, newFollower, _logger.Object);
44+
45+
var permChange = Assert.Single(changes.OfType<FollowerPermissionChange>());
46+
var script = Assert.Single(permChange.Scripts).Script!.Text;
47+
48+
Assert.Equal(".add follower database DDoSNeuralAnalysis viewers (\"aadapp=64decea3-723a-4fbf-b2ec-9faaf852cfdc;398a6654-997b-47e9-b12b-9515b896b4de\") 'leader-cluster'", script);
49+
}
50+
51+
[Fact]
52+
public void GeneratesFollowerAdd_WithoutLeaderName()
53+
{
54+
var oldFollower = BuildFollower();
55+
var newFollower = BuildFollower(("admin", "aaduser=foo;tenant", "Foo"));
56+
57+
var changes = DatabaseChanges.GenerateFollowerChanges(oldFollower, newFollower, _logger.Object);
58+
59+
var permChange = Assert.Single(changes.OfType<FollowerPermissionChange>());
60+
var script = Assert.Single(permChange.Scripts).Script!.Text;
61+
62+
Assert.Equal(".add follower database DDoSNeuralAnalysis admins (\"aaduser=foo;tenant\")", script);
63+
}
64+
65+
[Fact]
66+
public void EmitsDrop_WhenRemovingPrincipals()
67+
{
68+
var oldFollower = BuildFollower(("admin", "aadapp=1;tenant", "v1"));
69+
var newFollower = BuildFollower();
70+
71+
var changes = DatabaseChanges.GenerateFollowerChanges(oldFollower, newFollower, _logger.Object);
72+
73+
var permChange = Assert.Single(changes.OfType<FollowerPermissionChange>());
74+
var script = Assert.Single(permChange.Scripts).Script!.Text;
75+
76+
Assert.Equal(".drop follower database DDoSNeuralAnalysis admins (\"aadapp=1;tenant\")", script);
77+
}
78+
}
79+
}

KustoSchemaTools/Changes/DatabaseChanges.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,19 @@ .. GenerateFollowerCachingChanges(oldState, newState, db => db.MaterializedViews
224224

225225
];
226226

227+
var permissionChanges = new List<IChange>
228+
{
229+
new FollowerPermissionChange(newState.DatabaseName, "Admins", oldState.Permissions.Admins, newState.Permissions.Admins, newState.Permissions.LeaderName),
230+
new FollowerPermissionChange(newState.DatabaseName, "Viewers", oldState.Permissions.Viewers, newState.Permissions.Viewers, newState.Permissions.LeaderName)
231+
}.Where(itm => itm.Scripts.Any()).ToList();
232+
233+
if (permissionChanges.Any())
234+
{
235+
log.LogInformation($"Detected {permissionChanges.Count} follower permission changes");
236+
permissionChanges.Insert(0, new Heading("Permissions (Follower)"));
237+
result.AddRange(permissionChanges);
238+
}
239+
227240
if (oldState.Permissions.ModificationKind != newState.Permissions.ModificationKind)
228241
{
229242
var kind = newState.Permissions.ModificationKind.ToString().ToLower();
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using Kusto.Language;
2+
using KustoSchemaTools.Model;
3+
using KustoSchemaTools.Parser;
4+
using System.Text;
5+
6+
namespace KustoSchemaTools.Changes
7+
{
8+
// Generates follower-safe permission commands (add/drop) for admins/viewers.
9+
public class FollowerPermissionChange : BaseChange<List<AADObject>>
10+
{
11+
public FollowerPermissionChange(string db, string entity, List<AADObject> from, List<AADObject> to, string? leaderName)
12+
: base("FollowerPermissions", entity, from ?? new List<AADObject>(), to)
13+
{
14+
Db = db;
15+
LeaderName = leaderName;
16+
Init();
17+
}
18+
19+
public string Db { get; }
20+
public string? LeaderName { get; }
21+
22+
private static IEnumerable<string> PrincipalStrings(IEnumerable<AADObject> principals) =>
23+
principals.OrderBy(p => p.Id, StringComparer.OrdinalIgnoreCase).Select(a => "\"" + a.Id + "\"");
24+
25+
private string BuildAdd(IEnumerable<AADObject> principals)
26+
{
27+
var ids = string.Join(",", PrincipalStrings(principals));
28+
var leaderSuffix = string.IsNullOrWhiteSpace(LeaderName) ? string.Empty : $" '{LeaderName}'";
29+
return $".add follower database {Db.BracketIfIdentifier()} {Entity.ToLower()} ({ids}){leaderSuffix}";
30+
}
31+
32+
private string BuildDrop(IEnumerable<AADObject> principals)
33+
{
34+
var ids = string.Join(",", PrincipalStrings(principals));
35+
return $".drop follower database {Db.BracketIfIdentifier()} {Entity.ToLower()} ({ids})";
36+
}
37+
38+
private void Init()
39+
{
40+
var added = To.Where(itm => From.All(t => t.Id != itm.Id)).ToList();
41+
var removed = From.Where(itm => To.All(t => t.Id != itm.Id)).ToList();
42+
var changed = From.Join(To, f => f.Id, t => t.Id, (f, t) => new { f, t })
43+
.Where(x => x.f.Name != x.t.Name)
44+
.ToList();
45+
46+
if (removed.Any())
47+
{
48+
var script = new DatabaseScript { Text = BuildDrop(removed), Order = -1 };
49+
var container = new DatabaseScriptContainer(script, "FollowerPermissionChange");
50+
container.IsValid = !KustoCode.Parse(script.Text).GetDiagnostics().Any();
51+
Scripts.Add(container);
52+
}
53+
54+
if (added.Any())
55+
{
56+
var script = new DatabaseScript { Text = BuildAdd(added), Order = 0 };
57+
var container = new DatabaseScriptContainer(script, "FollowerPermissionChange");
58+
container.IsValid = !KustoCode.Parse(script.Text).GetDiagnostics().Any();
59+
Scripts.Add(container);
60+
}
61+
62+
if (changed.Any())
63+
{
64+
Scripts.Add(new DatabaseScriptContainer("FollowerPermissionRenamed", -1, "// No Database Change"));
65+
}
66+
67+
var sb = new StringBuilder();
68+
sb.AppendLine($"## {Entity} (Follower)");
69+
sb.AppendLine();
70+
sb.AppendLine("<table>");
71+
sb.AppendLine("<tr></tr>");
72+
73+
if (added.Any())
74+
{
75+
sb.AppendLine("<tr><td colspan=\"2\">Added:</td><td colspan=\"10\">" + string.Join("<br>", added.Select(t => $"{t.Name} ({t.Id})")) + "</td></tr>");
76+
}
77+
if (removed.Any())
78+
{
79+
sb.AppendLine("<tr><td colspan=\"2\">Removed:</td><td colspan=\"10\">" + string.Join("<br>", removed.Select(t => $"{t.Name} ({t.Id})")) + "</td></tr>");
80+
}
81+
if (changed.Any())
82+
{
83+
sb.AppendLine("<tr><td colspan=\"2\">Changed:</td><td colspan=\"10\">" + string.Join("<br>", changed.Select(t => $"{t.f.Name} => {t.t.Name} ({t.t.Id})")) + "</td></tr>");
84+
}
85+
86+
var logo = Scripts.Any() && Scripts.First().IsValid == false ? ":red_circle:" : ":green_circle:";
87+
var displayCmd = Scripts.FirstOrDefault()?.Script?.Text ?? "// No change";
88+
sb.AppendLine($"<tr><td colspan=\"2\">{logo}</td><td colspan=\"10\"><pre lang=\"kql\">{displayCmd.PrettifyKql()}</pre></td></tr>");
89+
sb.AppendLine("</table>");
90+
91+
Markdown = sb.ToString();
92+
}
93+
}
94+
}

KustoSchemaTools/Model/FollowerPermissions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ public class FollowerPermissions
55
public FollowerModificationKind ModificationKind { get; set; }
66
public List<AADObject> Viewers { get; set; } = new List<AADObject>();
77
public List<AADObject> Admins { get; set; } = new List<AADObject>();
8+
9+
// Optional leader name (as known to the follower) used by some follower commands
10+
public string? LeaderName { get; set; }
811
}
912

1013
}

KustoSchemaTools/Parser/KustoLoader/FollowerLoader.cs

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using KustoSchemaTools.Model;
2+
using KustoSchemaTools.Parser;
23
using Newtonsoft.Json;
34
using Newtonsoft.Json.Linq;
45
using System;
@@ -21,15 +22,24 @@ public class FollowerLoader
2122
| where isnotempty(Timespan)
2223
| limit 1
2324
)
24-
| summarize CachingPolicies=make_bag(bag_pack(Table,Timespan))
25-
)
26-
";
25+
| summarize CachingPolicies=make_bag(bag_pack(Table,Timespan)),
26+
DatabaseName=any(DatabaseName),
27+
LeaderClusterMetadataPath=any(LeaderClusterMetadataPath),
28+
CachingPolicyOverride=any(CachingPolicyOverride),
29+
AuthorizedPrincipalsOverride=any(AuthorizedPrincipalsOverride),
30+
AuthorizedPrincipalsModificationKind=any(AuthorizedPrincipalsModificationKind),
31+
CachingPoliciesModificationKind=any(CachingPoliciesModificationKind),
32+
ChildEntities=any(ChildEntities),
33+
OriginalDatabaseName=any(OriginalDatabaseName),
34+
IsAutoPrefetchEnabled=any(IsAutoPrefetchEnabled),
35+
LeaderName=any(LeaderName)
36+
)";
2737

2838
public static FollowerDatabase LoadFollower(string databaseName, KustoClient client)
2939
{
3040
var follower = new FollowerDatabase { DatabaseName = databaseName };
3141
// Execute the query and handle the case where no rows are returned (e.g., database is not a follower)
32-
var queryResult = client.Client.ExecuteQuery(string.Format(FollowerMetadataQuery, databaseName));
42+
var queryResult = client.Client.ExecuteQuery(string.Format(FollowerMetadataQuery, databaseName.BracketIfIdentifier()));
3343
var metdaData = queryResult.As<FollowerMetadata>().FirstOrDefault();
3444

3545
if (metdaData == null)
@@ -73,6 +83,48 @@ public static FollowerDatabase LoadFollower(string databaseName, KustoClient cli
7383
target.Add(key, kvp.Value.Days+"d");
7484
}
7585

86+
follower.Permissions.LeaderName = metdaData.LeaderName;
87+
88+
if (!string.IsNullOrWhiteSpace(metdaData.AuthorizedPrincipalsOverride))
89+
{
90+
try
91+
{
92+
var arr = JArray.Parse(metdaData.AuthorizedPrincipalsOverride);
93+
foreach (var principalObj in arr)
94+
{
95+
var role = principalObj["Role"]?.Value<int?>();
96+
var principal = principalObj["Principal"]?["FullyQualifiedName"]?.Value<string>()
97+
?? principalObj["Principal"]?["Id"]?.Value<string>();
98+
var displayName = principalObj["Principal"]?["DisplayName"]?.Value<string>()
99+
?? principal;
100+
101+
if (string.IsNullOrWhiteSpace(principal) || role == null)
102+
{
103+
continue;
104+
}
105+
106+
var aadObj = new AADObject
107+
{
108+
Id = principal,
109+
Name = displayName
110+
};
111+
112+
if (role == 0)
113+
{
114+
follower.Permissions.Admins.Add(aadObj);
115+
}
116+
else if (role == 2)
117+
{
118+
follower.Permissions.Viewers.Add(aadObj);
119+
}
120+
}
121+
}
122+
catch (Exception)
123+
{
124+
// Ignore parse errors; treat as empty and skip permission diffs
125+
}
126+
}
127+
76128
return follower;
77129
}
78130
}
@@ -81,6 +133,7 @@ public class FollowerMetadata
81133
{
82134
public string? DatabaseName { get; set; }
83135
public string? LeaderClusterMetadataPath { get; set; }
136+
public string? LeaderName { get; set; }
84137
public string? CachingPolicyOverride { get; set; }
85138
public string? AuthorizedPrincipalsOverride { get; set; }
86139
public string? AuthorizedPrincipalsModificationKind { get; set; }

KustoSchemaTools/Parser/KustoWriter/DefaultDatabaseWriter.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,36 @@ public class DefaultDatabaseWriter : IDBEntityWriter
1212
{
1313
public async Task WriteAsync(Database sourceDb, Database targetDb, KustoClient client, ILogger logger)
1414
{
15-
var changes = DatabaseChanges.GenerateChanges(targetDb, sourceDb, targetDb.Name, logger);
15+
var followerMeta = FollowerLoader.LoadFollower(targetDb.Name, client);
16+
var isFollower = followerMeta.Permissions.ModificationKind != FollowerModificationKind.None
17+
|| followerMeta.Cache.ModificationKind != FollowerModificationKind.None
18+
|| followerMeta.Cache.Tables.Any()
19+
|| followerMeta.Cache.MaterializedViews.Any();
20+
21+
List<IChange> changes;
22+
if (isFollower)
23+
{
24+
// Build desired follower from YAML/source DB
25+
var desiredFollower = new FollowerDatabase
26+
{
27+
DatabaseName = targetDb.Name,
28+
Permissions = new FollowerPermissions
29+
{
30+
ModificationKind = followerMeta.Permissions.ModificationKind,
31+
Admins = sourceDb.Admins,
32+
Viewers = sourceDb.Viewers,
33+
LeaderName = followerMeta.Permissions.LeaderName
34+
},
35+
Cache = followerMeta.Cache
36+
};
37+
38+
changes = DatabaseChanges.GenerateFollowerChanges(followerMeta, desiredFollower, logger);
39+
}
40+
else
41+
{
42+
changes = DatabaseChanges.GenerateChanges(targetDb, sourceDb, targetDb.Name, logger);
43+
}
44+
1645
var results = await ApplyChangesToDatabase(targetDb.Name, changes, client, logger);
1746

1847
foreach (var result in results)

0 commit comments

Comments
 (0)