Review 1.4: AX texts in API v1 and the hub without logins or host names; API has what the pages show

- "Ikke målbar" texts, notes and detection problems carry the GRANT text with
  the agent's login and database user. API v1 (batch-jobs, number-sequences,
  recids, databases, rule-states) returned them as they were, although logins
  are redacted everywhere else. AxReadAccess.WithoutPrincipals replaces them
  with the placeholder unless sensitive values are allowed; the grant script
  also hides a database user that differs from the login.
- SQLI-AX-010 put the host and program name (in 2-tier AX 3.0 the user's own
  PC) in the finding's explanation, which reaches the hub unredacted for an
  opted-in database. They are only in the sensitive evidence now, with the raw
  CONTEXT_INFO; the CONTEXT_INFO parsing says it is not verified against
  customer data.
- A database named e.g. "AX · test" was read from a subject as "AX", whose
  hub opt-in then applied. Middle dots in the database are doubled in AX
  subjects, and the AX community rules' subjects are built the same way.
- API v1 now also has the detection problems (/detection-problems), what each
  raw batch STATUS means, and the thresholds behind the number sequence and
  RecId pages; the blocking explanation is no longer redacted (it holds
  nothing sensitive now).

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 14:29:34 +02:00
parent 66ed4e11b3
commit f4d2603181
7 changed files with 183 additions and 42 deletions

View file

@ -83,8 +83,16 @@ public static class DynamicsAxFindings
ruleId?.StartsWith(RulePrefix, StringComparison.OrdinalIgnoreCase) == true || ruleId?.StartsWith(BuiltInRulePrefix, StringComparison.OrdinalIgnoreCase) == true; ruleId?.StartsWith(RulePrefix, StringComparison.OrdinalIgnoreCase) == true || ruleId?.StartsWith(BuiltInRulePrefix, StringComparison.OrdinalIgnoreCase) == true;
/// <summary>The subject of an AX finding about <paramref name="detail"/> in <paramref name="database"/>.</summary> /// <summary>The subject of an AX finding about <paramref name="detail"/> in <paramref name="database"/>.</summary>
public static string Subject(string database, string? detail = null) => /// <remarks>
detail is null ? DatabaseSubject + database : DatabaseSubject + database + SubjectSeparator + detail; /// A middle dot in the database name is doubled, so the separator " · " (one dot) can only be the separator and a
/// database named e.g. "AX · test" can never be read as the database "AX" (whose hub opt-in would then apply).
/// </remarks>
public static string Subject(string database, string? detail = null)
{
ArgumentNullException.ThrowIfNull(database);
var escaped = database.Replace("·", "··", StringComparison.Ordinal);
return detail is null ? DatabaseSubject + escaped : DatabaseSubject + escaped + SubjectSeparator + detail;
}
/// <summary>The database a finding is about, from its subject (<c>database: AX30</c> or <c>database: AX30 · …</c>), or null.</summary> /// <summary>The database a finding is about, from its subject (<c>database: AX30</c> or <c>database: AX30 · …</c>), or null.</summary>
public static string? DatabaseOf(string? subject) public static string? DatabaseOf(string? subject)
@ -96,6 +104,6 @@ public static class DynamicsAxFindings
var rest = subject[DatabaseSubject.Length..]; var rest = subject[DatabaseSubject.Length..];
var separator = rest.IndexOf(SubjectSeparator, StringComparison.Ordinal); var separator = rest.IndexOf(SubjectSeparator, StringComparison.Ordinal);
return separator > 0 ? rest[..separator] : rest; return (separator > 0 ? rest[..separator] : rest).Replace("··", "·", StringComparison.Ordinal);
} }
} }

View file

@ -1,4 +1,5 @@
using System.Text; using System.Text;
using System.Text.RegularExpressions;
namespace SQLInsight.Core.DynamicsAx; namespace SQLInsight.Core.DynamicsAx;
@ -74,6 +75,25 @@ public static class AxReadAccess
StringComparison.Ordinal); StringComparison.Ordinal);
} }
/// <summary>
/// <paramref name="text"/> without the agent's login or database user: the GRANT texts in "Ikke målbar", notes and
/// detection problems name them (CREATE USER [x] FOR LOGIN [x], SUSER_SID(N'x'), TO [user]), and so may a SQL Server
/// error message (principal "x"). Logins are sensitive like everywhere else, so API v1 shows the placeholder instead
/// unless sensitive values are allowed.
/// </summary>
public static string? WithoutPrincipals(string? text)
{
if (string.IsNullOrEmpty(text))
{
return text;
}
var placeholder = AxSql.Identifier(LoginPlaceholder);
text = Regex.Replace(text, @"(CREATE USER |FOR LOGIN | TO )\[(?:[^\]]|\]\])*\]", m => m.Groups[1].Value + placeholder);
text = Regex.Replace(text, @"SUSER_SID\(N'(?:[^']|'')*'\)", "SUSER_SID(" + AxSql.Literal(LoginPlaceholder) + ")");
return Regex.Replace(text, @"\b(principal|user|login)\s+""[^""]*""", "$1 \"[udeladt]\"", RegexOptions.IgnoreCase);
}
/// <summary>"Ikke målbar" for a check that needs <paramref name="tables"/>, with the exact GRANT text.</summary> /// <summary>"Ikke målbar" for a check that needs <paramref name="tables"/>, with the exact GRANT text.</summary>
public static string NotMeasurable(AxDatabase database, IReadOnlyList<string> tables, string? login = null) public static string NotMeasurable(AxDatabase database, IReadOnlyList<string> tables, string? login = null)
{ {

View file

@ -33,9 +33,11 @@ public static class AxSessionMapper
if (version is AxVersion.Axapta30 or AxVersion.Ax40) if (version is AxVersion.Axapta30 or AxVersion.Ax40)
{ {
return new AxSessionIdentity(null, null, "ikke tilgængelig", return new AxSessionIdentity(null, null, "ikke tilgængelig",
$"{AxVersions.Text(version)} sætter ikke CONTEXT_INFO, så AX-brugeren kan ikke ses fra SQL Server. " // No host or program name here: the explanation is not sensitive (it may reach SQLInsight Hub); they are
+ $"Vært: {session.HostName ?? "ukendt"} (AOS'en ved 3-lags-installation, brugerens egen pc ved 2-lags-klient). " // in the finding's sensitive evidence "Vært / program".
+ $"Program: {session.ProgramName ?? "ukendt"}. Brugeren kan findes i AX under Online brugere ud fra tidspunktet.", text); $"{AxVersions.Text(version)} sætter ikke CONTEXT_INFO, så AX-brugeren kan ikke ses fra SQL Server. Værten under målingerne er "
+ "AOS'en ved en 3-lags-installation og brugerens egen pc ved en 2-lags-klient. Brugeren kan findes i AX under Online brugere ud fra "
+ "tidspunktet og værten.", text);
} }
if (text is not null) if (text is not null)
@ -47,7 +49,8 @@ public static class AxSessionMapper
if (user is not null) if (user is not null)
{ {
return new AxSessionIdentity(user, axSession, "context_info", return new AxSessionIdentity(user, axSession, "context_info",
"Fra CONTEXT_INFO, som AOS'en sætter, når connectioncontext er slået til.", text); "Fra CONTEXT_INFO, som AOS'en sætter, når connectioncontext er slået til (første ord med bogstaver er taget som AX-brugeren; "
+ "tekstformatet er dokumenteret af Microsoft for AX 2012, men ikke verificeret mod kundedata – se den rå CONTEXT_INFO under målingerne).", text);
} }
} }

View file

@ -55,7 +55,7 @@ public sealed class DeclarativeRule(CustomRuleDefinition definition) : IRule
RuleId = Definition.Id, RuleId = Definition.Id,
Severity = Definition.Severity, Severity = Definition.Severity,
Title = CustomRuleDefinition.Fill(Definition.FindingTitle, row), Title = CustomRuleDefinition.Fill(Definition.FindingTitle, row),
Subject = Definition.Subject is { } subject ? CustomRuleDefinition.Fill(subject, row) : row.Values.FirstOrDefault(), Subject = Subject(row),
Explanation = CustomRuleDefinition.Fill(Definition.Explanation, row) Explanation = CustomRuleDefinition.Fill(Definition.Explanation, row)
+ (Definition.Origin == "community" ? " (Community-regel" + (Definition.Author is { } a ? $" af {a}" : "") + ".)" : " (Lokal regel.)"), + (Definition.Origin == "community" ? " (Community-regel" + (Definition.Author is { } a ? $" af {a}" : "") + ".)" : " (Lokal regel.)"),
Recommendation = CustomRuleDefinition.Fill(Definition.Recommendation, row), Recommendation = CustomRuleDefinition.Fill(Definition.Recommendation, row),
@ -68,4 +68,20 @@ public sealed class DeclarativeRule(CustomRuleDefinition definition) : IRule
}).ToArray(); }).ToArray();
return RuleEvaluation.Found(findings); return RuleEvaluation.Found(findings);
} }
/// <summary>
/// The finding's subject. A Dynamics AX rule's findings always start with the database they are about (built like the
/// built-in AX rules'), because the hub opt-in is per database: a subject that does not name it could never be sent, and
/// one that names it differently could be read as another database.
/// </summary>
private string? Subject(IReadOnlyDictionary<string, string?> row)
{
var subject = Definition.Subject is { } s ? CustomRuleDefinition.Fill(s, row) : row.Values.FirstOrDefault();
if (!Definition.DynamicsAx || !row.TryGetValue("database", out var database) || database is null)
{
return subject;
}
return Core.Configuration.DynamicsAxFindings.Subject(database, subject is null || subject == "database: " + database ? null : subject);
}
} }

View file

@ -181,7 +181,7 @@ public static class ApiV1Endpoints
.Select(r => new { r.Id, r.Title, Category = Category(r.Id), r.Source })); .Select(r => new { r.Id, r.Title, Category = Category(r.Id), r.Source }));
} }
private static async Task<IResult> RuleStatesAsync(string name, InsightRepository repository, CancellationToken ct) private static async Task<IResult> RuleStatesAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{ {
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance) if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
{ {
@ -201,7 +201,8 @@ public static class ApiV1Endpoints
Rules.Engine.RuleOutcome.ManualCheck => "manualCheck", Rules.Engine.RuleOutcome.ManualCheck => "manualCheck",
_ => "error", _ => "error",
}, },
s.Reason, // A reason may name the agent's login (the GRANT text of the Dynamics AX rules, or a permission error): redacted like logins elsewhere.
Reason = RedactorFor(options).Include ? s.Reason : Core.DynamicsAx.AxReadAccess.WithoutPrincipals(s.Reason),
s.EvaluatedAt, s.EvaluatedAt,
})); }));
} }

View file

@ -22,12 +22,15 @@ public static class DynamicsAxApiEndpoints
api.MapGet("/databases", DatabasesAsync) api.MapGet("/databases", DatabasesAsync)
.WithSummary("AX-databaser: version, skema, firmaer og læseadgang / Recognised Dynamics AX databases") .WithSummary("AX-databaser: version, skema, firmaer og læseadgang / Recognised Dynamics AX databases")
.WithDescription("Version (verified only for Axapta 3.0), schema owner (bmssa/dbo), model database, companies, which AX tables the agent may read, the missing tables and the GRANT that gives them. " + filter); .WithDescription("Version (verified only for Axapta 3.0), schema owner (bmssa/dbo), model database, companies, which AX tables the agent may read, the missing tables and the GRANT that gives them. " + filter);
api.MapGet("/detection-problems", DetectionProblemsAsync)
.WithSummary("Databaser, AX-genkendelsen ikke kunne læse / Databases the AX detection could not read")
.WithDescription("Per monitored database: why its metadata could not be read (typically no database user before SQL Server 2014), as shown under Dynamics AX → AX-databaser. Logins are redacted unless sensitive values are allowed. " + filter);
api.MapGet("/grant-script", GrantScriptAsync) api.MapGet("/grant-script", GrantScriptAsync)
.WithSummary("GRANT-sætninger til den valgfri AX-læseadgang / GRANT statements for the optional AX read access") .WithSummary("GRANT-sætninger til den valgfri AX-læseadgang / GRANT statements for the optional AX read access")
.WithDescription("?instance= and ?database= are required. Returns the exact statements for the database (only SELECT on the version's AX system tables) and the full grant-dynamics-ax-read.sql with database and login filled in."); .WithDescription("?instance= and ?database= are required. Returns the exact statements for the database (only SELECT on the version's AX system tables) and the full grant-dynamics-ax-read.sql with database and login filled in.");
api.MapGet("/batch-jobs", BatchJobsAsync) api.MapGet("/batch-jobs", BatchJobsAsync)
.WithSummary("AX-batchjob lige nu med deres egen historik / Dynamics AX batch jobs now") .WithSummary("AX-batchjob lige nu med deres egen historik / Dynamics AX batch jobs now")
.WithDescription("Status, planned and actual start (UTC), how late or how long running, median and 90th percentile of the job's own runs, the raw STATUS counts. " + filter); .WithDescription("Status, planned and actual start (UTC), how late or how long running, median and 90th percentile of the job's own runs, the raw STATUS counts and what each STATUS value means in this AX version. " + filter);
api.MapGet("/batch-runs", BatchRunsAsync) api.MapGet("/batch-runs", BatchRunsAsync)
.WithSummary("Afsluttede AX-batchkørsler / Completed Dynamics AX batch runs") .WithSummary("Afsluttede AX-batchkørsler / Completed Dynamics AX batch runs")
.WithDescription("From SQLInsight's repository, newest first. ?instance=, ?database=, ?job= (job key) and ?days= (1–365, default 7) filter; at most 1000."); .WithDescription("From SQLInsight's repository, newest first. ?instance=, ?database=, ?job= (job key) and ?days= (1–365, default 7) filter; at most 1000.");
@ -39,7 +42,7 @@ public static class DynamicsAxApiEndpoints
.WithDescription("NEXTVAL against the limit (32-bit in Axapta 3.0), per day and days left. " + filter); .WithDescription("NEXTVAL against the limit (32-bit in Axapta 3.0), per day and days left. " + filter);
api.MapGet("/sequence-samples", SamplesAsync) api.MapGet("/sequence-samples", SamplesAsync)
.WithSummary("Daglige målinger bag prognoserne / Daily samples behind the forecasts") .WithSummary("Daglige målinger bag prognoserne / Daily samples behind the forecasts")
.WithDescription("?instance= is required; ?database=, ?kind= (N = number sequence, R = RecId), ?key= and ?days= (1–400, default 30) filter."); .WithDescription("?instance= is required; ?database=, ?kind= (N = number sequence, R = RecId), ?key= and ?days= (1–400, default 30) filter. At most 10,000 rows; the header X-Truncated: true says there are more.");
api.MapGet("/blocking", BlockingAsync) api.MapGet("/blocking", BlockingAsync)
.WithSummary("AX-brugere bag blokering / Dynamics AX users behind blocking") .WithSummary("AX-brugere bag blokering / Dynamics AX users behind blocking")
.WithDescription("Blocking in AX databases with the AX user from CONTEXT_INFO (AX 2009/2012 with connectioncontext; never in Axapta 3.0/4.0, which the explanation says). ?days= (1–365, default 7). " + filter); .WithDescription("Blocking in AX databases with the AX user from CONTEXT_INFO (AX 2009/2012 with connectioncontext; never in Axapta 3.0/4.0, which the explanation says). ?days= (1–365, default 7). " + filter);
@ -48,18 +51,30 @@ public static class DynamicsAxApiEndpoints
private sealed class Redactor(bool include) private sealed class Redactor(bool include)
{ {
public string? Value(string? value) => !include && value is not null ? ApiV1Endpoints.Redacted : value; public string? Value(string? value) => !include && value is not null ? ApiV1Endpoints.Redacted : value;
/// <summary>A text that may name the agent's login or database user (GRANT texts, "Ikke målbar", detection problems).</summary>
public string? Text(string? text) => include ? text : AxReadAccess.WithoutPrincipals(text);
public List<string> Texts(IEnumerable<string> texts) => texts.Select(t => Text(t) ?? string.Empty).ToList();
} }
private static Redactor RedactorFor(IOptions<WebOptions> options) => new(options.Value.ApiIncludesSensitiveEvidence); private static Redactor RedactorFor(IOptions<WebOptions> options) => new(options.Value.ApiIncludesSensitiveEvidence);
private static async Task<(IReadOnlyList<AxInstanceView> Views, IResult? Error)> LoadAsync(InsightRepository repository, string? instance, CancellationToken ct) /// <summary>At most this many batch runs per call.</summary>
private const int MaxRuns = 1000;
/// <summary>At most this many daily samples per call (X-Truncated: true when there are more).</summary>
private const int MaxSamples = 10_000;
private static async Task<(IReadOnlyList<AxInstanceView> Views, IResult? Error)> LoadAsync(InsightRepository repository, string? instance, CancellationToken ct,
bool withProblems = false)
{ {
if (instance is { Length: > 0 } && await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false) is null) if (instance is { Length: > 0 } && await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false) is null)
{ {
return ([], Results.NotFound()); return ([], Results.NotFound());
} }
return (await DynamicsAxView.LoadAsync(repository, instance, ct).ConfigureAwait(false), null); return (await DynamicsAxView.LoadAsync(repository, instance, withProblems, ct).ConfigureAwait(false), null);
} }
private static async Task<IResult> DatabasesAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null) private static async Task<IResult> DatabasesAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null)
@ -85,23 +100,35 @@ public static class DynamicsAxApiEndpoints
d.Schema, d.Schema,
d.ModelDatabase, d.ModelDatabase,
Companies = d.Companies?.Select(c => new { c.Id, c.Name, c.IsVirtual }), Companies = d.Companies?.Select(c => new { c.Id, c.Name, c.IsVirtual }),
d.CompaniesReason, CompaniesReason = redact.Text(d.CompaniesReason),
Isolation = d.Isolation.ToString(), Isolation = d.Isolation.ToString(),
d.Notes, Notes = redact.Texts(d.Notes),
ReadAccess = AxReadAccess.Tables(d).Select(t => new { Table = d.Schema + "." + t, Readable = d.CanRead(t) }), ReadAccess = AxReadAccess.Tables(d).Select(t => new { Table = d.Schema + "." + t, Readable = d.CanRead(t) }),
MissingTables = missing, MissingTables = missing,
Grant = missing.Count == 0 ? null : RedactLogin(AxReadAccess.GrantSql(d, d.AgentLogin ?? v.Databases.AgentLogin, missing), d.AgentLogin ?? v.Databases.AgentLogin, redact), // The agent's login and database user are sensitive like every login: the placeholder unless sensitive values are allowed.
Grant = missing.Count == 0 ? null : redact.Text(AxReadAccess.GrantSql(d, d.AgentLogin ?? v.Databases.AgentLogin, missing)),
DetectedAt = v.Databases.CapturedAt, DetectedAt = v.Databases.CapturedAt,
}; };
}))); })));
} }
/// <summary>The agent's login is sensitive like every login: replaced by the placeholder unless sensitive values are allowed.</summary> private static async Task<IResult> DetectionProblemsAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null)
private static string RedactLogin(string sql, string? login, Redactor redact) => {
login is { Length: > 0 } && redact.Value(login) != login var (views, error) = await LoadAsync(repository, instance, ct, withProblems: true).ConfigureAwait(false);
? sql.Replace(AxSql.Identifier(login), AxSql.Identifier(AxReadAccess.LoginPlaceholder), StringComparison.Ordinal) if (error is not null)
.Replace(AxSql.Literal(login), AxSql.Literal(AxReadAccess.LoginPlaceholder), StringComparison.Ordinal) {
: sql; return error;
}
var redact = RedactorFor(options);
return Results.Ok(views.SelectMany(v => v.Databases.Problems.Select(p => new
{
Instance = v.Instance.Name,
Database = p.Key,
Problem = redact.Text(p.Value),
DetectedAt = v.Databases.CapturedAt,
})));
}
private static async Task<IResult> GrantScriptAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null, string? database = null) private static async Task<IResult> GrantScriptAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null, string? database = null)
{ {
@ -124,6 +151,11 @@ public static class DynamicsAxApiEndpoints
var redact = RedactorFor(options); var redact = RedactorFor(options);
var login = ax.AgentLogin ?? snapshot.AgentLogin; var login = ax.AgentLogin ?? snapshot.AgentLogin;
var shownLogin = redact.Value(login) == login ? login : AxReadAccess.LoginPlaceholder; var shownLogin = redact.Value(login) == login ? login : AxReadAccess.LoginPlaceholder;
if (shownLogin != login && ax.DatabaseUser is not null)
{
// The database user is as sensitive as the login.
ax = ax with { DatabaseUser = AxReadAccess.LoginPlaceholder };
}
return Results.Ok(new return Results.Ok(new
{ {
Instance = instance, Instance = instance,
@ -138,7 +170,7 @@ public static class DynamicsAxApiEndpoints
}); });
} }
private static async Task<IResult> BatchJobsAsync(InsightRepository repository, CancellationToken ct, string? instance = null) private static async Task<IResult> BatchJobsAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null)
{ {
var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false);
if (error is not null) if (error is not null)
@ -146,6 +178,7 @@ public static class DynamicsAxApiEndpoints
return error; return error;
} }
var redact = RedactorFor(options);
return Results.Ok(views.Where(v => v.Batch is not null).SelectMany(v => v.Batch!.Databases.Select(d => new return Results.Ok(views.Where(v => v.Batch is not null).SelectMany(v => v.Batch!.Databases.Select(d => new
{ {
Instance = v.Instance.Name, Instance = v.Instance.Name,
@ -153,9 +186,17 @@ public static class DynamicsAxApiEndpoints
Version = d.Version.ToString(), Version = d.Version.ToString(),
v.Batch!.CapturedAt, v.Batch!.CapturedAt,
v.Batch.Thresholds, v.Batch.Thresholds,
d.NotMeasurable, NotMeasurable = redact.Text(d.NotMeasurable),
d.Notes, Notes = redact.Texts(d.Notes),
StatusCounts = d.StatusCounts.ToDictionary(s => s.Key.ToString(System.Globalization.CultureInfo.InvariantCulture), s => s.Value), StatusCounts = d.StatusCounts.ToDictionary(s => s.Key.ToString(System.Globalization.CultureInfo.InvariantCulture), s => s.Value),
// What each raw STATUS means in this AX version, as the page shows it (Axapta 3.0/4.0's values are not verified).
Statuses = d.StatusCounts.OrderBy(s => s.Key).Select(s => new
{
Code = s.Key,
Status = AxSchemaAdapter.For(d.Version)?.BatchStatus(s.Key).ToString(),
Text = AxSchemaAdapter.For(d.Version) is { } adapter ? DynamicsAxView.StatusText(adapter.BatchStatus(s.Key)) : null,
Count = s.Value,
}),
Jobs = d.Jobs.Select(j => new Jobs = d.Jobs.Select(j => new
{ {
j.Key, j.RecId, j.Caption, j.Company, j.Group, Status = j.Status.ToString(), j.StatusCode, j.PlannedStartUtc, j.StartedUtc, j.EndedUtc, j.Key, j.RecId, j.Caption, j.Company, j.Group, Status = j.Status.ToString(), j.StatusCode, j.PlannedStartUtc, j.StartedUtc, j.EndedUtc,
@ -168,22 +209,29 @@ public static class DynamicsAxApiEndpoints
private static async Task<IResult> BatchRunsAsync(InsightRepository repository, CancellationToken ct, string? instance = null, string? database = null, string? job = null, private static async Task<IResult> BatchRunsAsync(InsightRepository repository, CancellationToken ct, string? instance = null, string? database = null, string? job = null,
int? days = null) int? days = null)
{ {
InstanceOverview? target = null; // The instances the AX pages show (not removed, with AX databases), each with its newest runs.
if (instance is { Length: > 0 } && (target = await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false)) is null) var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false);
if (error is not null)
{ {
return Results.NotFound(); return error;
} }
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var runs = await repository.GetAxBatchRunsAsync(target?.InstanceId, database, job, now.AddDays(-Math.Clamp(days ?? 7, 1, 365)), now, 1000, ct).ConfigureAwait(false); var runs = new List<StoredAxBatchRun>();
return Results.Ok(runs.Select(r => new foreach (var view in views)
{
runs.AddRange(await repository.GetAxBatchRunsAsync(view.Instance.InstanceId, database, job, now.AddDays(-Math.Clamp(days ?? 7, 1, 365)), now, MaxRuns, ct)
.ConfigureAwait(false));
}
return Results.Ok(runs.OrderByDescending(r => r.EndedAt).Take(MaxRuns).Select(r => new
{ {
Instance = r.InstanceName, r.DatabaseName, r.JobKey, r.RunKey, r.Caption, r.Company, r.StartedAt, r.EndedAt, DurationSeconds = r.Duration.TotalSeconds, Instance = r.InstanceName, r.DatabaseName, r.JobKey, r.RunKey, r.Caption, r.Company, r.StartedAt, r.EndedAt, DurationSeconds = r.Duration.TotalSeconds,
Status = r.Status.ToString(), Status = r.Status.ToString(),
})); }));
} }
private static async Task<IResult> NumberSequencesAsync(InsightRepository repository, CancellationToken ct, string? instance = null) private static async Task<IResult> NumberSequencesAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null)
{ {
var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false);
if (error is not null) if (error is not null)
@ -191,14 +239,16 @@ public static class DynamicsAxApiEndpoints
return error; return error;
} }
var redact = RedactorFor(options);
return Results.Ok(views.Where(v => v.Sequences is not null).SelectMany(v => v.Sequences!.Databases.Select(d => new return Results.Ok(views.Where(v => v.Sequences is not null).SelectMany(v => v.Sequences!.Databases.Select(d => new
{ {
Instance = v.Instance.Name, Instance = v.Instance.Name,
d.Database, d.Database,
Version = d.Version.ToString(), Version = d.Version.ToString(),
v.Sequences!.CapturedAt, v.Sequences!.CapturedAt,
NotMeasurable = d.NumberSequencesNotMeasurable, v.Sequences.Thresholds,
d.Notes, NotMeasurable = redact.Text(d.NumberSequencesNotMeasurable),
Notes = redact.Texts(d.Notes),
Sequences = d.NumberSequences.Select(s => new Sequences = d.NumberSequences.Select(s => new
{ {
s.Key, s.Company, s.Code, s.Text, s.Lowest, s.Highest, s.Next, s.Format, s.FormatDigits, s.FormatCapacity, s.FormatTooShort, s.Max, s.UsedPercent, s.Key, s.Company, s.Code, s.Text, s.Lowest, s.Highest, s.Next, s.Format, s.FormatDigits, s.FormatCapacity, s.FormatTooShort, s.Max, s.UsedPercent,
@ -208,7 +258,7 @@ public static class DynamicsAxApiEndpoints
}))); })));
} }
private static async Task<IResult> RecIdsAsync(InsightRepository repository, CancellationToken ct, string? instance = null) private static async Task<IResult> RecIdsAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null)
{ {
var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false);
if (error is not null) if (error is not null)
@ -216,13 +266,15 @@ public static class DynamicsAxApiEndpoints
return error; return error;
} }
var redact = RedactorFor(options);
return Results.Ok(views.Where(v => v.Sequences is not null).SelectMany(v => v.Sequences!.Databases.Select(d => new return Results.Ok(views.Where(v => v.Sequences is not null).SelectMany(v => v.Sequences!.Databases.Select(d => new
{ {
Instance = v.Instance.Name, Instance = v.Instance.Name,
d.Database, d.Database,
Version = d.Version.ToString(), Version = d.Version.ToString(),
v.Sequences!.CapturedAt, v.Sequences!.CapturedAt,
NotMeasurable = d.RecIdsNotMeasurable, v.Sequences.Thresholds,
NotMeasurable = redact.Text(d.RecIdsNotMeasurable),
RecIds = d.RecIds.Select(r => new RecIds = d.RecIds.Select(r => new
{ {
r.Key, r.TableId, r.TableName, r.Company, r.Next, r.Max, r.Bits, r.UsedPercent, r.Remaining, r.Key, r.TableId, r.TableName, r.Company, r.Next, r.Max, r.Bits, r.UsedPercent, r.Remaining,
@ -231,7 +283,8 @@ public static class DynamicsAxApiEndpoints
}))); })));
} }
private static async Task<IResult> SamplesAsync(InsightRepository repository, CancellationToken ct, string? instance = null, string? database = null, string? kind = null, private static async Task<IResult> SamplesAsync(HttpContext context, InsightRepository repository, CancellationToken ct, string? instance = null, string? database = null,
string? kind = null,
string? key = null, int? days = null) string? key = null, int? days = null)
{ {
if (instance is not { Length: > 0 } || await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false) is not { } target) if (instance is not { Length: > 0 } || await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false) is not { } target)
@ -251,9 +304,15 @@ public static class DynamicsAxApiEndpoints
return Results.BadRequest("kind skal være N (nummerserie) eller R (RecId)."); return Results.BadRequest("kind skal være N (nummerserie) eller R (RecId).");
} }
var samples = await repository.GetAxSequenceSamplesAsync(target.InstanceId, database, k, key, DateTimeOffset.UtcNow.AddDays(-Math.Clamp(days ?? 30, 1, 400)), ct) // Bounded in SQL; one row more than the limit tells the caller that ?database=, ?kind= or ?key= is needed for the rest.
.ConfigureAwait(false); var samples = await repository.GetAxSequenceSamplesAsync(target.InstanceId, database, k, key, DateTimeOffset.UtcNow.AddDays(-Math.Clamp(days ?? 30, 1, 400)),
return Results.Ok(samples.Take(10_000).Select(s => new { s.DatabaseName, Kind = s.Kind.ToString(), s.SequenceKey, s.SampledAt, s.Value })); MaxSamples + 1, ct).ConfigureAwait(false);
if (samples.Count > MaxSamples)
{
context.Response.Headers["X-Truncated"] = "true";
}
return Results.Ok(samples.Take(MaxSamples).Select(s => new { s.DatabaseName, Kind = s.Kind.ToString(), s.SequenceKey, s.SampledAt, s.Value }));
} }
private static async Task<IResult> BlockingAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null, int? days = null) private static async Task<IResult> BlockingAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? instance = null, int? days = null)
@ -269,7 +328,7 @@ public static class DynamicsAxApiEndpoints
return Results.Ok(rows.Take(1000).Select(r => new return Results.Ok(rows.Take(1000).Select(r => new
{ {
r.Instance, r.Database, Version = r.Version.ToString(), r.SampledAt, r.BlockedCount, r.MaxWaitMs, r.Instance, r.Database, Version = r.Version.ToString(), r.SampledAt, r.BlockedCount, r.MaxWaitMs,
AxUser = redact.Value(r.Identity.AxUser), r.Identity.AxSessionId, r.Identity.Source, Explanation = redact.Value(r.Identity.Explanation), AxUser = redact.Value(r.Identity.AxUser), r.Identity.AxSessionId, r.Identity.Source, r.Identity.Explanation,
Session = r.Head.SessionId, r.Head.Status, r.Head.OpenTransactionCount, Session = r.Head.SessionId, r.Head.Status, r.Head.OpenTransactionCount,
HostName = redact.Value(r.Head.HostName), ProgramName = redact.Value(r.Head.ProgramName), LoginName = redact.Value(r.Head.LoginName), HostName = redact.Value(r.Head.HostName), ProgramName = redact.Value(r.Head.ProgramName), LoginName = redact.Value(r.Head.LoginName),
SqlText = redact.Value(r.Head.SqlText), ContextInfo = redact.Value(r.Identity.RawContext), SqlText = redact.Value(r.Head.SqlText), ContextInfo = redact.Value(r.Identity.RawContext),

View file

@ -337,7 +337,9 @@ public class AxSessionMapperTests
Assert.False(identity.Known); Assert.False(identity.Known);
Assert.Contains("sætter ikke CONTEXT_INFO", identity.Explanation, StringComparison.Ordinal); Assert.Contains("sætter ikke CONTEXT_INFO", identity.Explanation, StringComparison.Ordinal);
Assert.Contains("AOS01", identity.Explanation, StringComparison.Ordinal); // Host and program are sensitive: in the finding's evidence, not in the explanation (which may reach the hub).
Assert.DoesNotContain("AOS01", identity.Explanation, StringComparison.Ordinal);
Assert.Contains("Online brugere", identity.Explanation, StringComparison.Ordinal);
} }
[Fact] [Fact]
@ -475,3 +477,35 @@ public class AxLocalTimeTests
Assert.Equal(Utc(2026, 6, 30, 22, 0), sequence.LastCleanUtc); Assert.Equal(Utc(2026, 6, 30, 22, 0), sequence.LastCleanUtc);
} }
} }
public class AxRedactionTests
{
[Fact]
public void A_database_name_with_the_separator_is_never_read_as_another_database()
{
var subject = DynamicsAxFindings.Subject("AX · test", "batch: 1");
Assert.Equal("AX · test", DynamicsAxFindings.DatabaseOf(subject));
Assert.Equal("AX · test", DynamicsAxFindings.DatabaseOf(DynamicsAxFindings.Subject("AX · test")));
Assert.Equal("a··b", DynamicsAxFindings.DatabaseOf(DynamicsAxFindings.Subject("a··b", "recid: 1/dat")));
Assert.Equal("database: AX30 · batch: 1", DynamicsAxFindings.Subject("AX30", "batch: 1"));
}
[Fact]
public void Grant_texts_and_error_messages_lose_the_login_and_the_database_user()
{
var database = AxFixtures.Recognise(AxFixtures.Ax30, readable: false, user: "svc") with { AgentLogin = @"KUNDE\svc-sqlinsight$" };
var text = AxReadAccess.NotMeasurable(database, ["BATCH"]);
Assert.Contains("svc", text, StringComparison.Ordinal);
var redacted = AxReadAccess.WithoutPrincipals(text)!;
Assert.DoesNotContain("KUNDE", redacted, StringComparison.Ordinal);
Assert.DoesNotContain("[svc]", redacted, StringComparison.Ordinal);
Assert.Contains("GRANT SELECT ON [bmssa].[BATCH] TO [" + AxReadAccess.LoginPlaceholder + "]", redacted, StringComparison.Ordinal);
var noUser = AxReadAccess.WithoutPrincipals(AxReadAccess.GrantSql(database with { DatabaseUser = null }, @"KUNDE\svc-sqlinsight$"))!;
Assert.DoesNotContain("KUNDE", noUser, StringComparison.Ordinal);
Assert.Equal("The server principal \"[udeladt]\" is not able to access the database",
AxReadAccess.WithoutPrincipals("The server principal \"KUNDE\\svc\" is not able to access the database"));
}
}