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>
85 lines
4.6 KiB
C#
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()}";
|
|
}
|
|
}
|