sqlinsight-pro/src/SQLInsight.Core/DynamicsAx/AxReadAccess.cs
Søren Eggert Lundsteen Olsen 4ebae4efe6 Dynamics AX core: central detector, per-version schema adapters, read-access GRANT
One place recognises AX (version 3.0/4.0/2009/2012/2012 R2/F&O, schema owner,
model store) from metadata only. Per-version adapters hold all table/column
knowledge, check every column in sys.columns before use and degrade to
'ikke målbar' with the exact column or the exact GRANT text. Only AX 3.0 is
verified against a real customer schema; 2009/2012 are written from
Microsoft's documented schema (docs/dynamics-ax/*.documented.tsv, replaced
by a real export when one is dropped in). grant-dynamics-ax-read.sql is
version- and schema-aware (bmssa) and covers exactly the adapters' tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:52:58 +02:00

85 lines
4.6 KiB
C#

using System.Text;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// The optional read access to AX tables. The agent normally reads only metadata (grant-least-privilege.sql); the AX
/// data checks need SELECT on a few AX system tables (batch jobs, number sequences, RecId allocation, companies), and
/// the customer decides whether to give it, per database. No customer data tables, never more than SELECT.
/// db/permissions/grant-dynamics-ax-read.sql does the same for any database; <see cref="GrantSql"/> makes the exact
/// statements for one database, with its schema (bmssa in AX 3.0) and the agent's login filled in.
/// </summary>
public static class AxReadAccess
{
public const string ScriptPath = "db/permissions/grant-dynamics-ax-read.sql";
public const string RevokeScriptPath = "db/permissions/revoke-dynamics-ax-read.sql";
/// <summary>Used in the GRANT text when the agent's login is not known yet.</summary>
public const string LoginPlaceholder = @"DOMÆNE\svc-sqlinsight$";
/// <summary>The tables the read access covers for a version (those that exist in the database).</summary>
public static IReadOnlyList<string> Tables(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
return AxSchemaAdapter.For(database.Version) is { } adapter
? adapter.ReadTables.Where(database.HasTable).ToList()
: [];
}
/// <summary>The tables the agent may not read yet.</summary>
public static IReadOnlyList<string> Missing(AxDatabase database) => Tables(database).Where(t => !database.CanRead(t)).ToList();
/// <summary>
/// The exact statements that give the agent SELECT on <paramref name="tables"/> (all the version's tables when null)
/// in this database. Run by a sysadmin or db_owner; SQLInsight never runs it.
/// </summary>
public static string GrantSql(AxDatabase database, string? login, IEnumerable<string>? tables = null)
{
ArgumentNullException.ThrowIfNull(database);
login = string.IsNullOrWhiteSpace(login) ? LoginPlaceholder : login.Trim();
var user = database.DatabaseUser ?? login;
var sql = new StringBuilder()
.Append("USE ").Append(AxSql.Identifier(database.Database)).Append(";\r\n");
if (database.DatabaseUser is null)
{
sql.Append("IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE sid = SUSER_SID(").Append(AxSql.Literal(login)).Append("))\r\n")
.Append(" CREATE USER ").Append(AxSql.Identifier(login)).Append(" FOR LOGIN ").Append(AxSql.Identifier(login)).Append(";\r\n");
}
foreach (var table in (tables ?? Tables(database)).Distinct(StringComparer.OrdinalIgnoreCase))
{
sql.Append("GRANT SELECT ON ").Append(database.Qualified(table)).Append(" TO ").Append(AxSql.Identifier(user)).Append(";\r\n");
}
return sql.ToString();
}
/// <summary>db/permissions/grant-dynamics-ax-read.sql as shipped.</summary>
public static string Template()
{
using var stream = typeof(AxReadAccess).Assembly.GetManifestResourceStream("SQLInsight.Core.grant-dynamics-ax-read.sql")
?? throw new InvalidOperationException("grant-dynamics-ax-read.sql mangler i programmet.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
/// <summary>The shipped script with the database and the agent's login filled in.</summary>
public static string Script(string database, string? login)
{
ArgumentNullException.ThrowIfNull(database);
return Template()
.Replace("SET @DatabaseName = N'AXDB';", "SET @DatabaseName = " + AxSql.Literal(database) + ";", StringComparison.Ordinal)
.Replace("SET @LoginName = N'" + LoginPlaceholder + "';", "SET @LoginName = " + AxSql.Literal(string.IsNullOrWhiteSpace(login) ? LoginPlaceholder : login.Trim()) + ";",
StringComparison.Ordinal);
}
/// <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)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(tables);
return $"Ikke målbar: agenten må ikke læse {string.Join(", ", tables.Select(t => database.Schema + "." + t))} i {database.Database} "
+ $"(valgfri AX-læseadgang). Kør som sysadmin eller db_owner: {GrantSql(database, login ?? database.AgentLogin, tables).Replace("\r\n", " ", StringComparison.Ordinal).Trim()}";
}
}