From 08cd741b52bf8d847a9915d337d1d246d3157e02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Fri, 2 Oct 2026 13:51:37 +0200 Subject: [PATCH] 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) --- docs/dynamics-ax.md | 6 +- .../Custom/CustomQueryCollector.cs | 3 +- .../DynamicsAx/AxDatabaseDetector.cs | 7 +- .../Components/Pages/AxBatchPage.razor | 74 +++++++++---------- .../Pages/AxNumberSequencesPage.razor | 58 +++++++-------- .../Components/Pages/AxRecIdPage.razor | 34 ++++----- .../DynamicsAxSqlTests.cs | 4 +- .../SqlScriptTests.cs | 1 - .../HubEndToEndTests.cs | 5 +- 9 files changed, 103 insertions(+), 89 deletions(-) diff --git a/docs/dynamics-ax.md b/docs/dynamics-ax.md index f48d080..339b112 100644 --- a/docs/dynamics-ax.md +++ b/docs/dynamics-ax.md @@ -17,7 +17,7 @@ En database er AX, når systemtabellerne `SQLDICTIONARY` og `DATAAREA` ligger i | `PARTITION` og modeldatabasen `_model` på instansen | AX 2012 R2/R3 | | `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 @@ -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`). + +## 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`. diff --git a/src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs b/src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs index 0c600ce..71d2547 100644 --- a/src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs +++ b/src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs @@ -40,6 +40,7 @@ public sealed class CustomQueryCollector(CustomRuleCatalog catalog, DynamicsAx.A try { List? databases = null; + HashSet? axDatabases = null; foreach (var rule in rules) { if (rule.Scope == CustomRuleScope.Server) @@ -53,7 +54,7 @@ public sealed class CustomQueryCollector(CustomRuleCatalog catalog, DynamicsAx.A if (rule.DynamicsAx) { // 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) : []; var targets = databases.Where(axDatabases.Contains).ToList(); diff --git a/src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs b/src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs index a405d17..dfb604b 100644 --- a/src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs +++ b/src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs @@ -15,6 +15,11 @@ public sealed class AxDatabaseDetector { public static readonly TimeSpan Refresh = TimeSpan.FromHours(1); + /// An AX database without the full read access is looked at again sooner, so a GRANT takes effect within minutes. + 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 _cache = new(StringComparer.OrdinalIgnoreCase); private string? _login; @@ -44,7 +49,7 @@ public sealed class AxDatabaseDetector 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); _cache[name] = cached; diff --git a/src/SQLInsight.Web/Components/Pages/AxBatchPage.razor b/src/SQLInsight.Web/Components/Pages/AxBatchPage.razor index 0e401b5..2b11d4d 100644 --- a/src/SQLInsight.Web/Components/Pages/AxBatchPage.razor +++ b/src/SQLInsight.Web/Components/Pages/AxBatchPage.razor @@ -31,45 +31,45 @@ } else { - @foreach (var note in db.Notes) - { -

@note

- } - - @if (db.Jobs.Count == 0) - { -

Ingen batchjob.

- } - else - { - - - - @foreach (var job in db.Jobs.Take(200)) - { - var late = job.Late(now); - var running = job.Running(now); - - - - - - - - - - - } - -
StatusBatchjobFirmaPlanlagt startKører / forsinketMedian90 %Kørsler
@DynamicsAxView.StatusText(job.Status) (@job.StatusCode)@job.Caption@(job.Company ?? "–")@Display.Time(job.PlannedStartUtc)@(running is { } r ? "kører " + Minutes(r) : late is { } l ? "forsinket " + Minutes(l) : "–")@Seconds(job.MedianSeconds)@Seconds(job.P90Seconds)@job.HistoryRuns
- } - -

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)) + @foreach (var note in db.Notes) { - 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. +

@note

} -

+ + @if (db.Jobs.Count == 0) + { +

Ingen batchjob.

+ } + else + { + + + + @foreach (var job in db.Jobs.Take(200)) + { + var late = job.Late(now); + var running = job.Running(now); + + + + + + + + + + + } + +
StatusBatchjobFirmaPlanlagt startKører / forsinketMedian90 %Kørsler
@DynamicsAxView.StatusText(job.Status) (@job.StatusCode)@job.Caption@(job.Company ?? "–")@Display.Time(job.PlannedStartUtc)@(running is { } r ? "kører " + Minutes(r) : late is { } l ? "forsinket " + Minutes(l) : "–")@Seconds(job.MedianSeconds)@Seconds(job.P90Seconds)@job.HistoryRuns
+ } + +

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)) + { + 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. + } +

} } diff --git a/src/SQLInsight.Web/Components/Pages/AxNumberSequencesPage.razor b/src/SQLInsight.Web/Components/Pages/AxNumberSequencesPage.razor index bafad64..e75a1a6 100644 --- a/src/SQLInsight.Web/Components/Pages/AxNumberSequencesPage.razor +++ b/src/SQLInsight.Web/Components/Pages/AxNumberSequencesPage.razor @@ -31,36 +31,36 @@ } else { - @foreach (var note in db.Notes) - { -

@note

- } + @foreach (var note in db.Notes) + { +

@note

+ } - - - - @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; - - - - - - - - - - - - - } - -
FirmaNummerserieTekstNæsteHøjesteFormatBrugtPr. dagDage tilbageBemærk
@(s.Company ?? "delt")@s.Code@s.Text@Display.Number(s.Next)@Display.Number(s.Highest)@s.Format@Display.Number(s.UsedPercent, 1) %@(s.Forecast is { } f ? Display.Number(f.PerDay, 1) : "–")@(s.Forecast?.DaysLeft is { } d ? Display.Number(d) : "–")@Flags(s)
- @if (db.NumberSequences.Count > 200) - { -

Viser de 200 mest brugte af @db.NumberSequences.Count. Alle findes i API'et.

- } + + + + @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; + + + + + + + + + + + + + } + +
FirmaNummerserieTekstNæsteHøjesteFormatBrugtPr. dagDage tilbageBemærk
@(s.Company ?? "delt")@s.Code@s.Text@Display.Number(s.Next)@Display.Number(s.Highest)@s.Format@Display.Number(s.UsedPercent, 1) %@(s.Forecast is { } f ? Display.Number(f.PerDay, 1) : "–")@(s.Forecast?.DaysLeft is { } d ? Display.Number(d) : "–")@Flags(s)
+ @if (db.NumberSequences.Count > 200) + { +

Viser de 200 mest brugte af @db.NumberSequences.Count. Alle findes i API'et.

+ } } } diff --git a/src/SQLInsight.Web/Components/Pages/AxRecIdPage.razor b/src/SQLInsight.Web/Components/Pages/AxRecIdPage.razor index 8103afd..a380aba 100644 --- a/src/SQLInsight.Web/Components/Pages/AxRecIdPage.razor +++ b/src/SQLInsight.Web/Components/Pages/AxRecIdPage.razor @@ -30,23 +30,23 @@ } else { - - - - @foreach (var r in db.RecIds.Take(100)) - { - - - - - - - - - - } - -
TabelFirmaNæste RecIdGrænseBrugtPr. dagDage tilbage
@(r.TableName ?? $"tabel-id {r.TableId}")@(r.Company ?? "–")@Display.Number(r.Next)@Display.Number(r.Max) @(r.Bits == 32 ? "(32-bit)" : "")@Display.Number(r.UsedPercent, 2) %@(r.Forecast is { } f ? Display.Number(f.PerDay) : "–")@(r.Forecast?.DaysLeft is { } d ? Display.Number(d) : "–")
+ + + + @foreach (var r in db.RecIds.Take(100)) + { + + + + + + + + + + } + +
TabelFirmaNæste RecIdGrænseBrugtPr. dagDage tilbage
@(r.TableName ?? $"tabel-id {r.TableId}")@(r.Company ?? "–")@Display.Number(r.Next)@Display.Number(r.Max) @(r.Bits == 32 ? "(32-bit)" : "")@Display.Number(r.UsedPercent, 2) %@(r.Forecast is { } f ? Display.Number(f.PerDay) : "–")@(r.Forecast?.DaysLeft is { } d ? Display.Number(d) : "–")
} } diff --git a/tests/SQLInsight.Collectors.Tests/DynamicsAxSqlTests.cs b/tests/SQLInsight.Collectors.Tests/DynamicsAxSqlTests.cs index b56ea18..8940eff 100644 --- a/tests/SQLInsight.Collectors.Tests/DynamicsAxSqlTests.cs +++ b/tests/SQLInsight.Collectors.Tests/DynamicsAxSqlTests.cs @@ -36,7 +36,9 @@ public sealed class DynamicsAxSqlTests { Assert.DoesNotContain(DiscoveryTests.Sql2008Only, p => p.IsMatch(sql)); 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)\]\.\[(?[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.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); diff --git a/tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs b/tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs index 3ec1081..9ed3d5b 100644 --- a/tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs +++ b/tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs @@ -61,7 +61,6 @@ public sealed class SqlScriptTests Assert.Contains("Versioner:", header, StringComparison.Ordinal); Assert.Contains("Collector:", header, StringComparison.Ordinal); Assert.Contains("ikke verificeret mod kundedata", header, StringComparison.Ordinal); - Assert.DoesNotContain("PXI_", header, StringComparison.OrdinalIgnoreCase); } /// File name → the generated SQL. diff --git a/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs b/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs index 0509940..c6a98c5 100644 --- a/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs +++ b/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs @@ -71,7 +71,10 @@ public class HubEndToEndTests(SqlServerFixture sql, HubFixture hub) : IClassFixt Assert.Null(instance.ServerName); // not sent by default 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.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()); Assert.Equal("e2e-sql01", latest.InstanceName);