Dynamics AX: notice a GRANT within minutes, one AX lookup per custom-rule run

The detector looks again every 5 minutes at an AX database where the agent
lacks part of the read access, so a GRANT takes effect quickly. The AX SQL
test checks that only standard AX tables are read. The hub end-to-end test
expects no AX rule outcomes without the opt-in. Docs: what 1.5's synthetic
AX checks can reuse.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 13:51:37 +02:00
parent cd72f14883
commit 08cd741b52
9 changed files with 103 additions and 89 deletions

View file

@ -17,7 +17,7 @@ En database er AX, når systemtabellerne `SQLDICTIONARY` og `DATAAREA` ligger i
| `PARTITION` og modeldatabasen `<db>_model` på instansen | AX 2012 R2/R3 | | `PARTITION` og modeldatabasen `<db>_model` på instansen | AX 2012 R2/R3 |
| `PARTITION`, intet modellager | Dynamics 365 F&O on-premises (formodet) | | `PARTITION`, intet modellager | Dynamics 365 F&O on-premises (formodet) |
Genkendelsen læser kun metadata (`sys.tables`, `sys.columns`, `HAS_PERMS_BY_NAME`) og kræver ikke mere end [`grant-least-privilege.sql`](../db/permissions/grant-least-privilege.sql). Den kører hver time i de databaser, der er valgt til overvågning. **AX-tjek kører kun i databaser, der er genkendt som AX og valgt til overvågning**; de generelle tjek kører i alle valgte databaser som hidtil. Genkendelsen læser kun metadata (`sys.tables`, `sys.columns`, `HAS_PERMS_BY_NAME`) og kræver ikke mere end [`grant-least-privilege.sql`](../db/permissions/grant-least-privilege.sql). Den kører hver time i de databaser, der er valgt til overvågning (hvert 5. minut for en AX-database, hvor agenten endnu ikke har den fulde læseadgang, så en GRANT virker inden for få minutter). **AX-tjek kører kun i databaser, der er genkendt som AX og valgt til overvågning**; de generelle tjek kører i alle valgte databaser som hidtil.
## Valgfri læseadgang til AX-tabellerne ## Valgfri læseadgang til AX-tabellerne
@ -119,3 +119,7 @@ ORDER BY s.name, t.name, c.column_id;
``` ```
og gemmes tabulatorsepareret som `docs/dynamics-ax/schema-ax2009.tsv` eller `schema-ax2012.tsv`. Testene (`AxSchemaAdapterTests`, `DynamicsAxSqlTests`, `SqlScriptTests`) bruger så det rigtige skema i stedet for det dokumenterede, og viser præcis hvilke kolonner versionens adapter ([`AxSchemaAdapter.cs`](../src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs)) forventer, men som mangler. Al viden om en versions tabeller ligger i dens adapter; ret den dér, og opdatér teksterne "ikke verificeret mod kundedata" for versionen (`AxVersions.IsVerified`). og gemmes tabulatorsepareret som `docs/dynamics-ax/schema-ax2009.tsv` eller `schema-ax2012.tsv`. Testene (`AxSchemaAdapterTests`, `DynamicsAxSqlTests`, `SqlScriptTests`) bruger så det rigtige skema i stedet for det dokumenterede, og viser præcis hvilke kolonner versionens adapter ([`AxSchemaAdapter.cs`](../src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs)) forventer, men som mangler. Al viden om en versions tabeller ligger i dens adapter; ret den dér, og opdatér teksterne "ikke verificeret mod kundedata" for versionen (`AxVersions.IsVerified`).
## Genbrug i andre tjek
Et syntetisk AX-logon-tjek (fase 1.5) hører ikke til her, men kan genbruge AX-viden herfra i stedet for at kende AX' tabeller selv: `GET /api/v1/dynamics-ax/databases` (eller snapshot'et `dynamics-ax` i repository'et, typen `AxDatabaseSnapshot`) giver for hver overvåget AX-database version, skema, firmaer (`DATAAREA`) og modeldatabase; `AxDetection` og `AxSchemaAdapter` i `SQLInsight.Core.DynamicsAx` er den eneste kilde til, hvilke tabeller og kolonner en version har; og `AxSessionMapper` oversætter en SQL-session til AX-bruger og AX-session, hvor AOS'en sætter `CONTEXT_INFO`.

View file

@ -40,6 +40,7 @@ public sealed class CustomQueryCollector(CustomRuleCatalog catalog, DynamicsAx.A
try try
{ {
List<string>? databases = null; List<string>? databases = null;
HashSet<string>? axDatabases = null;
foreach (var rule in rules) foreach (var rule in rules)
{ {
if (rule.Scope == CustomRuleScope.Server) if (rule.Scope == CustomRuleScope.Server)
@ -53,7 +54,7 @@ public sealed class CustomQueryCollector(CustomRuleCatalog catalog, DynamicsAx.A
if (rule.DynamicsAx) if (rule.DynamicsAx)
{ {
// The AX detection needs SQL Server 2005 (sys.columns); AX rules have nowhere to run before that. // The AX detection needs SQL Server 2005 (sys.columns); AX rules have nowhere to run before that.
var axDatabases = context.Instance.Version.Major >= 9 axDatabases ??= context.Instance.Version.Major >= 9
? (await _ax.GetAsync(context).ConfigureAwait(false)).Databases.Select(d => d.Database).ToHashSet(StringComparer.OrdinalIgnoreCase) ? (await _ax.GetAsync(context).ConfigureAwait(false)).Databases.Select(d => d.Database).ToHashSet(StringComparer.OrdinalIgnoreCase)
: []; : [];
var targets = databases.Where(axDatabases.Contains).ToList(); var targets = databases.Where(axDatabases.Contains).ToList();

View file

@ -15,6 +15,11 @@ public sealed class AxDatabaseDetector
{ {
public static readonly TimeSpan Refresh = TimeSpan.FromHours(1); public static readonly TimeSpan Refresh = TimeSpan.FromHours(1);
/// <summary>An AX database without the full read access is looked at again sooner, so a GRANT takes effect within minutes.</summary>
public static readonly TimeSpan RefreshWithoutReadAccess = TimeSpan.FromMinutes(5);
private static TimeSpan RefreshFor(AxDatabase? database) => database is not null && AxReadAccess.Missing(database).Count > 0 ? RefreshWithoutReadAccess : Refresh;
private readonly Dictionary<string, (DateTimeOffset At, AxDatabase? Database, string? Problem)> _cache = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, (DateTimeOffset At, AxDatabase? Database, string? Problem)> _cache = new(StringComparer.OrdinalIgnoreCase);
private string? _login; private string? _login;
@ -44,7 +49,7 @@ public sealed class AxDatabaseDetector
continue; continue;
} }
if (force || !_cache.TryGetValue(name, out var cached) || context.Now - cached.At >= Refresh) if (force || !_cache.TryGetValue(name, out var cached) || context.Now - cached.At >= RefreshFor(cached.Database))
{ {
cached = await DetectAsync(context, name, names).ConfigureAwait(false); cached = await DetectAsync(context, name, names).ConfigureAwait(false);
_cache[name] = cached; _cache[name] = cached;

View file

@ -31,45 +31,45 @@
} }
else else
{ {
@foreach (var note in db.Notes) @foreach (var note in db.Notes)
{
<p class="muted small">@note</p>
}
@if (db.Jobs.Count == 0)
{
<p class="muted">Ingen batchjob.</p>
}
else
{
<table>
<thead><tr><th>Status</th><th>Batchjob</th><th>Firma</th><th>Planlagt start</th><th>Kører / forsinket</th><th class="num">Median</th><th class="num">90 %</th><th class="num">Kørsler</th></tr></thead>
<tbody>
@foreach (var job in db.Jobs.Take(200))
{
var late = job.Late(now);
var running = job.Running(now);
<tr class="@(job.Status == AxBatchStatus.Error || late?.TotalMinutes >= view.Batch.Thresholds.BatchLateMinutes ? "bad" : "")">
<td>@DynamicsAxView.StatusText(job.Status) <span class="muted small">(@job.StatusCode)</span></td>
<td>@job.Caption</td>
<td>@(job.Company ?? "–")</td>
<td class="nowrap">@Display.Time(job.PlannedStartUtc)</td>
<td>@(running is { } r ? "kører " + Minutes(r) : late is { } l ? "forsinket " + Minutes(l) : "–")</td>
<td class="num">@Seconds(job.MedianSeconds)</td>
<td class="num">@Seconds(job.P90Seconds)</td>
<td class="num">@job.HistoryRuns</td>
</tr>
}
</tbody>
</table>
}
<p class="muted small">STATUS-værdier fundet: @string.Join(", ", db.StatusCounts.OrderBy(s => s.Key).Select(s => $"{s.Key} = {DynamicsAxView.StatusText(AxSchemaAdapter.For(db.Version)?.BatchStatus(s.Key) ?? AxBatchStatus.Unknown)} ({s.Value})")).
@if (!AxVersions.HasUtcBatchTimes(db.Version))
{ {
<text> Oversættelsen af STATUS i @AxVersions.Text(db.Version) er ikke verificeret mod kundedata; sammenlign med batchlisten i AX. Tider gemmes af AX som lokal dato + sekunder efter midnat og vises omregnet.</text> <p class="muted small">@note</p>
} }
</p>
@if (db.Jobs.Count == 0)
{
<p class="muted">Ingen batchjob.</p>
}
else
{
<table>
<thead><tr><th>Status</th><th>Batchjob</th><th>Firma</th><th>Planlagt start</th><th>Kører / forsinket</th><th class="num">Median</th><th class="num">90 %</th><th class="num">Kørsler</th></tr></thead>
<tbody>
@foreach (var job in db.Jobs.Take(200))
{
var late = job.Late(now);
var running = job.Running(now);
<tr class="@(job.Status == AxBatchStatus.Error || late?.TotalMinutes >= view.Batch.Thresholds.BatchLateMinutes ? "bad" : "")">
<td>@DynamicsAxView.StatusText(job.Status) <span class="muted small">(@job.StatusCode)</span></td>
<td>@job.Caption</td>
<td>@(job.Company ?? "–")</td>
<td class="nowrap">@Display.Time(job.PlannedStartUtc)</td>
<td>@(running is { } r ? "kører " + Minutes(r) : late is { } l ? "forsinket " + Minutes(l) : "–")</td>
<td class="num">@Seconds(job.MedianSeconds)</td>
<td class="num">@Seconds(job.P90Seconds)</td>
<td class="num">@job.HistoryRuns</td>
</tr>
}
</tbody>
</table>
}
<p class="muted small">STATUS-værdier fundet: @string.Join(", ", db.StatusCounts.OrderBy(s => s.Key).Select(s => $"{s.Key} = {DynamicsAxView.StatusText(AxSchemaAdapter.For(db.Version)?.BatchStatus(s.Key) ?? AxBatchStatus.Unknown)} ({s.Value})")).
@if (!AxVersions.HasUtcBatchTimes(db.Version))
{
<text> Oversættelsen af STATUS i @AxVersions.Text(db.Version) er ikke verificeret mod kundedata; sammenlign med batchlisten i AX. Tider gemmes af AX som lokal dato + sekunder efter midnat og vises omregnet.</text>
}
</p>
} }
</section> </section>
} }

View file

@ -31,36 +31,36 @@
} }
else else
{ {
@foreach (var note in db.Notes) @foreach (var note in db.Notes)
{ {
<p class="muted small">@note</p> <p class="muted small">@note</p>
} }
<table> <table>
<thead><tr><th>Firma</th><th>Nummerserie</th><th>Tekst</th><th class="num">Næste</th><th class="num">Højeste</th><th>Format</th><th class="num">Brugt</th><th class="num">Pr. dag</th><th class="num">Dage tilbage</th><th>Bemærk</th></tr></thead> <thead><tr><th>Firma</th><th>Nummerserie</th><th>Tekst</th><th class="num">Næste</th><th class="num">Højeste</th><th>Format</th><th class="num">Brugt</th><th class="num">Pr. dag</th><th class="num">Dage tilbage</th><th>Bemærk</th></tr></thead>
<tbody> <tbody>
@foreach (var s in db.NumberSequences.Take(200)) @foreach (var s in db.NumberSequences.Take(200))
{ {
var bad = !s.Manual && !s.Cyclic && !s.Blocked && (s.UsedPercent >= t.NumberSequenceWarnPercent || s.Forecast?.DaysLeft <= t.ForecastWarnDays) || s.FormatTooShort; var bad = !s.Manual && !s.Cyclic && !s.Blocked && (s.UsedPercent >= t.NumberSequenceWarnPercent || s.Forecast?.DaysLeft <= t.ForecastWarnDays) || s.FormatTooShort;
<tr class="@(bad ? "bad" : "")"> <tr class="@(bad ? "bad" : "")">
<td>@(s.Company ?? "delt")</td> <td>@(s.Company ?? "delt")</td>
<td class="mono">@s.Code</td> <td class="mono">@s.Code</td>
<td>@s.Text</td> <td>@s.Text</td>
<td class="num">@Display.Number(s.Next)</td> <td class="num">@Display.Number(s.Next)</td>
<td class="num">@Display.Number(s.Highest)</td> <td class="num">@Display.Number(s.Highest)</td>
<td class="mono">@s.Format</td> <td class="mono">@s.Format</td>
<td class="num">@Display.Number(s.UsedPercent, 1) %</td> <td class="num">@Display.Number(s.UsedPercent, 1) %</td>
<td class="num">@(s.Forecast is { } f ? Display.Number(f.PerDay, 1) : "–")</td> <td class="num">@(s.Forecast is { } f ? Display.Number(f.PerDay, 1) : "–")</td>
<td class="num">@(s.Forecast?.DaysLeft is { } d ? Display.Number(d) : "–")</td> <td class="num">@(s.Forecast?.DaysLeft is { } d ? Display.Number(d) : "–")</td>
<td>@Flags(s)</td> <td>@Flags(s)</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
@if (db.NumberSequences.Count > 200) @if (db.NumberSequences.Count > 200)
{ {
<p class="muted small">Viser de 200 mest brugte af @db.NumberSequences.Count. Alle findes i API'et.</p> <p class="muted small">Viser de 200 mest brugte af @db.NumberSequences.Count. Alle findes i API'et.</p>
} }
} }
</section> </section>
} }

View file

@ -30,23 +30,23 @@
} }
else else
{ {
<table> <table>
<thead><tr><th>Tabel</th><th>Firma</th><th class="num">Næste RecId</th><th class="num">Grænse</th><th class="num">Brugt</th><th class="num">Pr. dag</th><th class="num">Dage tilbage</th></tr></thead> <thead><tr><th>Tabel</th><th>Firma</th><th class="num">Næste RecId</th><th class="num">Grænse</th><th class="num">Brugt</th><th class="num">Pr. dag</th><th class="num">Dage tilbage</th></tr></thead>
<tbody> <tbody>
@foreach (var r in db.RecIds.Take(100)) @foreach (var r in db.RecIds.Take(100))
{ {
<tr class="@(Rules.DynamicsAx.AxRecIdExhaustionRule.Assess(r, view.Sequences.Thresholds) is null ? "" : "bad")"> <tr class="@(Rules.DynamicsAx.AxRecIdExhaustionRule.Assess(r, view.Sequences.Thresholds) is null ? "" : "bad")">
<td>@(r.TableName ?? $"tabel-id {r.TableId}")</td> <td>@(r.TableName ?? $"tabel-id {r.TableId}")</td>
<td>@(r.Company ?? "–")</td> <td>@(r.Company ?? "–")</td>
<td class="num">@Display.Number(r.Next)</td> <td class="num">@Display.Number(r.Next)</td>
<td class="num">@Display.Number(r.Max) @(r.Bits == 32 ? "(32-bit)" : "")</td> <td class="num">@Display.Number(r.Max) @(r.Bits == 32 ? "(32-bit)" : "")</td>
<td class="num">@Display.Number(r.UsedPercent, 2) %</td> <td class="num">@Display.Number(r.UsedPercent, 2) %</td>
<td class="num">@(r.Forecast is { } f ? Display.Number(f.PerDay) : "–")</td> <td class="num">@(r.Forecast is { } f ? Display.Number(f.PerDay) : "–")</td>
<td class="num">@(r.Forecast?.DaysLeft is { } d ? Display.Number(d) : "–")</td> <td class="num">@(r.Forecast?.DaysLeft is { } d ? Display.Number(d) : "–")</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
} }
</section> </section>
} }

View file

@ -36,7 +36,9 @@ public sealed class DynamicsAxSqlTests
{ {
Assert.DoesNotContain(DiscoveryTests.Sql2008Only, p => p.IsMatch(sql)); Assert.DoesNotContain(DiscoveryTests.Sql2008Only, p => p.IsMatch(sql));
Assert.DoesNotContain("NOLOCK", sql, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("NOLOCK", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("PXI_", sql, StringComparison.OrdinalIgnoreCase); // Only AX's own standard tables, never anything else.
var tables = System.Text.RegularExpressions.Regex.Matches(sql, @"\[(?:bmssa|dbo)\]\.\[(?<t>[A-Z_]+)\]").Select(m => m.Groups["t"].Value).ToList();
Assert.All(tables, t => Assert.Contains(t, AxDetection.KnownTables));
Assert.DoesNotContain("USE ", sql, StringComparison.Ordinal); Assert.DoesNotContain("USE ", sql, StringComparison.Ordinal);
Assert.DoesNotMatch(@"\b(INSERT|UPDATE|DELETE|MERGE|TRUNCATE|ALTER|CREATE|DROP|EXEC\s*\()\b", sql.Replace("sp_executesql", string.Empty, StringComparison.Ordinal)); Assert.DoesNotMatch(@"\b(INSERT|UPDATE|DELETE|MERGE|TRUNCATE|ALTER|CREATE|DROP|EXEC\s*\()\b", sql.Replace("sp_executesql", string.Empty, StringComparison.Ordinal));
Assert.StartsWith("EXEC [AXDB].sys.sp_executesql", sql, StringComparison.Ordinal); Assert.StartsWith("EXEC [AXDB].sys.sp_executesql", sql, StringComparison.Ordinal);

View file

@ -61,7 +61,6 @@ public sealed class SqlScriptTests
Assert.Contains("Versioner:", header, StringComparison.Ordinal); Assert.Contains("Versioner:", header, StringComparison.Ordinal);
Assert.Contains("Collector:", header, StringComparison.Ordinal); Assert.Contains("Collector:", header, StringComparison.Ordinal);
Assert.Contains("ikke verificeret mod kundedata", header, StringComparison.Ordinal); Assert.Contains("ikke verificeret mod kundedata", header, StringComparison.Ordinal);
Assert.DoesNotContain("PXI_", header, StringComparison.OrdinalIgnoreCase);
} }
/// <summary>File name → the generated SQL.</summary> /// <summary>File name → the generated SQL.</summary>

View file

@ -71,7 +71,10 @@ public class HubEndToEndTests(SqlServerFixture sql, HubFixture hub) : IClassFixt
Assert.Null(instance.ServerName); // not sent by default Assert.Null(instance.ServerName); // not sent by default
var findings = await db.Findings.Where(f => f.InstanceId == instance.Id && f.ResolvedAt == null).ToListAsync(); var findings = await db.Findings.Where(f => f.InstanceId == instance.Id && f.ResolvedAt == null).ToListAsync();
Assert.Contains(findings, f => f.RuleId == "SQLI-CFG-001"); Assert.Contains(findings, f => f.RuleId == "SQLI-CFG-001");
Assert.True(await db.RuleStates.CountAsync(r => r.InstanceId == instance.Id) >= BuiltInRules.Create().Count); // Every built-in rule's outcome, except the Dynamics AX rules: no database has AX findings to the hub turned on.
Assert.True(await db.RuleStates.CountAsync(r => r.InstanceId == instance.Id)
>= BuiltInRules.Create().Count(r => !Core.Configuration.DynamicsAxFindings.IsDynamicsAxRule(r.Id)));
Assert.False(await db.RuleStates.AnyAsync(r => r.InstanceId == instance.Id && r.RuleId.StartsWith("SQLI-AX-")));
var latest = Assert.Single(await _repository.GetLatestSubmissionsAsync()); var latest = Assert.Single(await _repository.GetLatestSubmissionsAsync());
Assert.Equal("e2e-sql01", latest.InstanceName); Assert.Equal("e2e-sql01", latest.InstanceName);