Skip to content

Commit 824f8da

Browse files
committed
follower perms
1 parent 793d8bd commit 824f8da

File tree

7 files changed

+350
-13
lines changed

7 files changed

+350
-13
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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 GeneratesFollowerSetCommand_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(".set follower database DDoSNeuralAnalysis viewers (\"aadapp=64decea3-723a-4fbf-b2ec-9faaf852cfdc;398a6654-997b-47e9-b12b-9515b896b4de\") 'leader-cluster'", script);
49+
Assert.DoesNotContain(".set database", script);
50+
}
51+
52+
[Fact]
53+
public void GeneratesFollowerSetCommand_WithoutLeaderName()
54+
{
55+
var oldFollower = BuildFollower();
56+
var newFollower = BuildFollower(("admin", "aaduser=foo;tenant", "Foo"));
57+
58+
var changes = DatabaseChanges.GenerateFollowerChanges(oldFollower, newFollower, _logger.Object);
59+
60+
var permChange = Assert.Single(changes.OfType<FollowerPermissionChange>());
61+
var script = Assert.Single(permChange.Scripts).Script!.Text;
62+
63+
Assert.Equal(".set follower database DDoSNeuralAnalysis admins (\"aaduser=foo;tenant\")", script);
64+
Assert.DoesNotContain("leader", script, StringComparison.OrdinalIgnoreCase);
65+
}
66+
67+
[Fact]
68+
public void NoChange_WhenPrincipalsUnchanged()
69+
{
70+
var oldFollower = BuildFollower(("viewer", "aadapp=1;tenant", "v1"));
71+
var newFollower = BuildFollower(("viewer", "aadapp=1;tenant", "v1"));
72+
73+
var changes = DatabaseChanges.GenerateFollowerChanges(oldFollower, newFollower, _logger.Object);
74+
75+
Assert.Empty(changes.OfType<FollowerPermissionChange>());
76+
}
77+
78+
[Fact]
79+
public void EmitsNoneWithoutParens_WhenClearingPrincipals()
80+
{
81+
var oldFollower = BuildFollower(("admin", "aadapp=1;tenant", "v1"));
82+
var newFollower = BuildFollower();
83+
84+
var changes = DatabaseChanges.GenerateFollowerChanges(oldFollower, newFollower, _logger.Object);
85+
86+
var permChange = Assert.Single(changes.OfType<FollowerPermissionChange>());
87+
var script = Assert.Single(permChange.Scripts).Script!.Text;
88+
89+
Assert.Equal(".set follower database DDoSNeuralAnalysis admins none", script);
90+
}
91+
}
92+
}

KustoSchemaTools/Changes/DatabaseChanges.cs

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,21 +217,25 @@ private static List<IChange> GenerateScriptCompareChanges<T>(Database oldState,
217217

218218
public static List<IChange> GenerateFollowerChanges(FollowerDatabase oldState, FollowerDatabase newState, ILogger log)
219219
{
220-
List<IChange> result =
221-
[
222-
.. GenerateFollowerCachingChanges(oldState, newState, db => db.Tables, "Table", "table"),
223-
.. GenerateFollowerCachingChanges(oldState, newState, db => db.MaterializedViews, "MV", "materialized-view"),
224-
225-
];
220+
var result = new List<IChange>();
226221

222+
// Ensure principals-modification-kind is applied before any permission updates
223+
// by adding it first (earlier in the ordered script list).
227224
if (oldState.Permissions.ModificationKind != newState.Permissions.ModificationKind)
228225
{
229226
var kind = newState.Permissions.ModificationKind.ToString().ToLower();
230227
result.Add(new BasicChange("FollowerDatabase", "PermissionsModificationKind", $" Change Permission-Modification-Kind from {oldState.Permissions.ModificationKind} to {newState.Permissions.ModificationKind}", new List<DatabaseScriptContainer>
231228
{
232-
new DatabaseScriptContainer(new DatabaseScript($".alter follower database {newState.DatabaseName.BracketIfIdentifier()} principals-modification-kind = {kind}", 0), "FollowerChangePolicyModificationKind")
229+
new DatabaseScriptContainer(new DatabaseScript($".alter follower database {newState.DatabaseName.BracketIfIdentifier()} principals-modification-kind = {kind}", -10), "FollowerChangePolicyModificationKind")
233230
}));
234231
}
232+
233+
result.AddRange(GenerateFollowerCachingChanges(oldState, newState, db => db.Tables, "Table", "table"));
234+
result.AddRange(GenerateFollowerCachingChanges(oldState, newState, db => db.MaterializedViews, "MV", "materialized-view"));
235+
236+
// Permission changes (order defaults to 0) now follow the modification-kind change above.
237+
result.AddRange(GenerateFollowerPermissionChanges(oldState, newState, log));
238+
235239
if (oldState.Cache.ModificationKind != newState.Cache.ModificationKind)
236240
{
237241
var kind = newState.Cache.ModificationKind.ToString().ToLower();
@@ -332,6 +336,26 @@ private static List<IChange> GenerateFollowerCachingChanges(FollowerDatabase old
332336

333337
return result;
334338
}
339+
340+
private static IEnumerable<IChange> GenerateFollowerPermissionChanges(FollowerDatabase oldState, FollowerDatabase newState, ILogger log)
341+
{
342+
var changes = new List<IChange>();
343+
344+
var permissionChanges = new List<IChange>
345+
{
346+
new FollowerPermissionChange(newState.DatabaseName, "Admins", oldState.Permissions.Admins, newState.Permissions.Admins, newState.Permissions.LeaderName, oldState.Permissions.LeaderName),
347+
new FollowerPermissionChange(newState.DatabaseName, "Viewers", oldState.Permissions.Viewers, newState.Permissions.Viewers, newState.Permissions.LeaderName, oldState.Permissions.LeaderName)
348+
}.Where(itm => itm.Scripts.Any()).ToList();
349+
350+
if (permissionChanges.Any())
351+
{
352+
log.LogInformation($"Detected {permissionChanges.Count} follower permission changes");
353+
permissionChanges.Insert(0, new Heading("Permissions (Follower)"));
354+
}
355+
356+
changes.AddRange(permissionChanges);
357+
return changes;
358+
}
335359
}
336360

337361
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using Kusto.Language;
2+
using KustoSchemaTools.Model;
3+
using KustoSchemaTools.Parser;
4+
using System.Text;
5+
6+
namespace KustoSchemaTools.Changes
7+
{
8+
/// <summary>
9+
/// Permission diff for follower databases. Uses follower-specific commands so
10+
/// we don't attempt to write directly to the read-only follower database.
11+
/// </summary>
12+
public class FollowerPermissionChange : BaseChange<List<AADObject>>
13+
{
14+
public FollowerPermissionChange(string db, string entity, List<AADObject> from, List<AADObject> to, string? leaderName, string? currentLeaderName)
15+
: base("FollowerPermissions", entity, from ?? new List<AADObject>(), to)
16+
{
17+
Db = db;
18+
LeaderName = leaderName;
19+
CurrentLeaderName = currentLeaderName;
20+
Init();
21+
}
22+
23+
public string Db { get; }
24+
public string? LeaderName { get; }
25+
public string? CurrentLeaderName { get; }
26+
27+
private string BuildCommand(List<AADObject> principals, string? leaderName)
28+
{
29+
// Order-insensitive: sort by Id so equivalent sets don't emit churn
30+
var ids = principals
31+
.OrderBy(p => p.Id, StringComparer.OrdinalIgnoreCase)
32+
.Select(a => "\"" + a.Id + "\""
33+
);
34+
35+
// Kusto control commands expect the literal keyword 'none' when no principals
36+
// are supplied. Wrapping none in parentheses makes it a principal named "none"
37+
// and the command is rejected, so only use parentheses for non-empty sets.
38+
var idsFragment = ids.Any() ? $"({string.Join(",", ids)})" : "none";
39+
40+
// Leader name is optional; when provided we append it the same way the
41+
// portal does (e.g. '.add follower database DB viewers (...) "leader"').
42+
var leaderSuffix = string.IsNullOrWhiteSpace(leaderName) ? string.Empty : $" '{leaderName}'";
43+
44+
return $".set follower database {Db.BracketIfIdentifier()} {Entity.ToLower()} {idsFragment}{leaderSuffix}";
45+
}
46+
47+
private void Init()
48+
{
49+
var targetCmd = BuildCommand(To, LeaderName);
50+
var currentCmd = BuildCommand(From, CurrentLeaderName);
51+
52+
if (!string.Equals(targetCmd, currentCmd, StringComparison.Ordinal))
53+
{
54+
var script = new DatabaseScript { Text = targetCmd, Order = 0 };
55+
var container = new DatabaseScriptContainer(script, "FollowerPermissionChange");
56+
var code = KustoCode.Parse(script.Text);
57+
container.IsValid = !code.GetDiagnostics().Any();
58+
Scripts.Add(container);
59+
}
60+
61+
var added = To.Where(itm => From.All(t => t.Id != itm.Id)).ToList();
62+
var removed = From.Where(itm => To.All(t => t.Id != itm.Id)).ToList();
63+
var changed = From.Join(To, f => f.Id, t => t.Id, (f, t) => new { f, t })
64+
.Where(x => x.f.Name != x.t.Name)
65+
.ToList();
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+
Scripts.Add(new DatabaseScriptContainer("FollowerPermissionRenamed", -1, "// No Database Change"));
84+
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>");
85+
}
86+
87+
var logo = Scripts.Any() && Scripts.First().IsValid == false ? ":red_circle:" : ":green_circle:";
88+
var displayCmd = Scripts.FirstOrDefault()?.Script?.Text ?? currentCmd;
89+
sb.AppendLine($"<tr><td colspan=\"2\">{logo}</td><td colspan=\"10\"><pre lang=\"kql\">{displayCmd.PrettifyKql()}</pre></td></tr>");
90+
sb.AppendLine("</table>");
91+
92+
Markdown = sb.ToString();
93+
}
94+
}
95+
}

KustoSchemaTools/Model/FollowerDatabase.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ public class FollowerDatabase
66
public FollowerCache Cache { get; set; } = new FollowerCache();
77
// TODO: No logic to load data / roll out changes implemented yet!
88
public FollowerPermissions Permissions { get; set; } = new FollowerPermissions();
9+
10+
// Populated when available from follower metadata (e.g., Data Share followers)
11+
public string? LeaderClusterMetadataPath { get; set; }
912
}
1013

1114
}

KustoSchemaTools/Model/FollowerPermissions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ 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+
/// <summary>
10+
/// Optional leader/follower name as known to the cluster. Some follower commands
11+
/// (e.g. .add follower database ... ) allow/require the leader name. We keep it
12+
/// here in case future YAML/metadata provides it. Not currently required for
13+
/// .set follower database operations.
14+
/// </summary>
15+
public string? LeaderName { get; set; }
816
}
917

1018
}

KustoSchemaTools/Parser/KustoLoader/FollowerLoader.cs

Lines changed: 62 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,53 @@ 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+
follower.LeaderClusterMetadataPath = metdaData.LeaderClusterMetadataPath;
88+
89+
// Parse principals override so we can diff/emit follower permission changes
90+
if (!string.IsNullOrWhiteSpace(metdaData.AuthorizedPrincipalsOverride))
91+
{
92+
try
93+
{
94+
var arr = JArray.Parse(metdaData.AuthorizedPrincipalsOverride);
95+
foreach (var principalObj in arr)
96+
{
97+
var role = principalObj["Role"]?.Value<int?>();
98+
var principal = principalObj["Principal"]?["FullyQualifiedName"]?.Value<string>()
99+
?? principalObj["Principal"]?["Id"]?.Value<string>();
100+
var displayName = principalObj["Principal"]?["DisplayName"]?.Value<string>()
101+
?? principal;
102+
103+
if (string.IsNullOrWhiteSpace(principal) || role == null)
104+
{
105+
continue;
106+
}
107+
108+
var aadObj = new AADObject
109+
{
110+
Id = principal,
111+
Name = displayName
112+
};
113+
114+
// Role mapping per Kusto: 0=Admin,1=User,2=Viewer. We only
115+
// support Admin/Viewer for followers today.
116+
if (role == 0)
117+
{
118+
follower.Permissions.Admins.Add(aadObj);
119+
}
120+
else if (role == 2)
121+
{
122+
follower.Permissions.Viewers.Add(aadObj);
123+
}
124+
}
125+
}
126+
catch (Exception)
127+
{
128+
// Ignore parse errors; permissions will be treated as empty and
129+
// no changes will be generated.
130+
}
131+
}
132+
76133
return follower;
77134
}
78135
}
@@ -81,6 +138,7 @@ public class FollowerMetadata
81138
{
82139
public string? DatabaseName { get; set; }
83140
public string? LeaderClusterMetadataPath { get; set; }
141+
public string? LeaderName { get; set; }
84142
public string? CachingPolicyOverride { get; set; }
85143
public string? AuthorizedPrincipalsOverride { get; set; }
86144
public string? AuthorizedPrincipalsModificationKind { get; set; }

0 commit comments

Comments
 (0)