Collectors: monitor only chosen databases; database discovery and permission check
CollectorContext carries the instance's DatabaseSelection; the database, index, Query Store and community-rule collectors skip databases that are not chosen (system databases always run). DatabaseDiscovery lists every database with state, size, recovery model, compatibility level and access, and recognises Dynamics AX with the same signature as the AX rules, through three-part names only (2000 and 2005+ variants). PermissionCheck reports which rights from grant-least-privilege.sql the login has (HAS_PERMS_BY_NAME, version-aware; not possible on 2000). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1199b1aa86
commit
b2fa78411f
10 changed files with 447 additions and 6 deletions
|
|
@ -45,7 +45,8 @@ public sealed class CustomQueryCollector(CustomRuleCatalog catalog) : Collector<
|
|||
continue;
|
||||
}
|
||||
|
||||
databases ??= await Db.QueryAsync(context.Connection, DatabasesSql, r => r.String(0), context.CancellationToken).ConfigureAwait(false);
|
||||
databases ??= (await Db.QueryAsync(context.Connection, DatabasesSql, r => r.String(0), context.CancellationToken).ConfigureAwait(false))
|
||||
.Where(context.IncludesDatabase).ToList();
|
||||
results[rule.Id] = await RunPerDatabaseAsync(context, rule, databases).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ public sealed class DatabaseCollector : Collector<DatabaseSnapshot>
|
|||
files = await Db.QueryAsync(connection, Files2000, MapFile, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Only the databases chosen for monitoring (system databases always are).
|
||||
databases = databases.Where(d => context.IncludesDatabase(d.Name)).ToList();
|
||||
|
||||
// msdb and DATABASEPROPERTYEX report server local time; everything downstream works in UTC.
|
||||
var offsetMinutes = (await Db.QueryAsync(connection, Db.UtcOffsetSql, r => r.Int(0), ct).ConfigureAwait(false))[0];
|
||||
DateTime? ToUtc(DateTime? local) => local is { Year: > 1900 } value ? DateTime.SpecifyKind(value.AddMinutes(-offsetMinutes), DateTimeKind.Utc) : local;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ public sealed class IndexCollector : Collector<IndexSnapshot>
|
|||
var connection = context.Connection;
|
||||
var ct = context.CancellationToken;
|
||||
var startTime = await Db.StartTimeAsync(connection, context.Instance.Version, ct).ConfigureAwait(false);
|
||||
var names = await Db.QueryAsync(connection, DatabasesSql, r => r.String(0), ct).ConfigureAwait(false);
|
||||
var names = (await Db.QueryAsync(connection, DatabasesSql, r => r.String(0), ct).ConfigureAwait(false)).Where(context.IncludesDatabase).ToList();
|
||||
var missing = await Db.QueryAsync(connection, MissingSql, r => (Database: r.NullableString(0), Index: new MissingIndex
|
||||
{
|
||||
Table = r.String(1),
|
||||
|
|
|
|||
110
src/SQLInsight.Collectors/Discovery/DatabaseDiscovery.cs
Normal file
110
src/SQLInsight.Collectors/Discovery/DatabaseDiscovery.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using System.Data.Common;
|
||||
using SQLInsight.Core.Configuration;
|
||||
using SQLInsight.Core.Instances;
|
||||
|
||||
namespace SQLInsight.Collectors.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Lists every database the agent can see on an instance, with state, size, recovery model and compatibility level,
|
||||
/// and recognises Dynamics AX databases. Only metadata is read (sys.databases, sys.master_files, sys.tables and
|
||||
/// sys.columns, or their SQL Server 2000 counterparts), through three-part names: the session never changes database
|
||||
/// and nothing is written. Works from SQL Server 2000 (over the legacy ODBC connection) and up.
|
||||
/// </summary>
|
||||
public static class DatabaseDiscovery
|
||||
{
|
||||
/// <summary>SQL Server 2005 and later.</summary>
|
||||
internal const string Databases2005 = """
|
||||
SELECT d.name, d.database_id, d.state_desc, d.recovery_model_desc, d.compatibility_level, d.is_read_only,
|
||||
CASE WHEN d.source_database_id IS NULL THEN 0 ELSE 1 END,
|
||||
HAS_DBACCESS(d.name),
|
||||
(SELECT SUM(CAST(f.size AS bigint)) / 128 FROM sys.master_files f WHERE f.database_id = d.database_id)
|
||||
FROM sys.databases d
|
||||
ORDER BY d.name
|
||||
""";
|
||||
|
||||
/// <summary>SQL Server 2000: no sys.databases; status and recovery model from DATABASEPROPERTYEX.</summary>
|
||||
internal const string Databases2000 = """
|
||||
SELECT d.name, d.dbid,
|
||||
CAST(DATABASEPROPERTYEX(d.name, 'Status') AS nvarchar(60)),
|
||||
CAST(DATABASEPROPERTYEX(d.name, 'Recovery') AS nvarchar(60)),
|
||||
d.cmptlevel,
|
||||
CASE WHEN DATABASEPROPERTYEX(d.name, 'Updateability') = 'READ_ONLY' THEN 1 ELSE 0 END,
|
||||
0,
|
||||
HAS_DBACCESS(d.name),
|
||||
(SELECT SUM(CAST(f.size AS bigint)) / 128 FROM master.dbo.sysaltfiles f WHERE f.dbid = d.dbid)
|
||||
FROM master.dbo.sysdatabases d
|
||||
ORDER BY d.name
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// The Dynamics AX signature, the same as the AX community rules use (rules/community/dynamics-ax.json): the system
|
||||
/// tables SQLDICTIONARY, DATAAREA and SYSCONFIG, in any schema (AX 3.0 owns them as bmssa). RECID on SQLDICTIONARY
|
||||
/// is int (precision 10) in AX 3.0 and bigint (19) from AX 4.0.
|
||||
/// </summary>
|
||||
public static string DynamicsAxSql(string database, SqlServerVersion version)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
var db = Db.Identifier(database);
|
||||
return version.Major >= 9
|
||||
? $"""
|
||||
SELECT COUNT(DISTINCT t.name), MAX(CASE WHEN t.name = N'SQLDICTIONARY' THEN c.precision END)
|
||||
FROM {db}.sys.tables t
|
||||
LEFT JOIN {db}.sys.columns c ON c.object_id = t.object_id AND c.name = N'RECID'
|
||||
WHERE t.name IN (N'SQLDICTIONARY', N'DATAAREA', N'SYSCONFIG')
|
||||
"""
|
||||
: $"""
|
||||
SELECT COUNT(DISTINCT o.name), MAX(CASE WHEN o.name = N'SQLDICTIONARY' THEN c.xprec END)
|
||||
FROM {db}.dbo.sysobjects o
|
||||
LEFT JOIN {db}.dbo.syscolumns c ON c.id = o.id AND c.name = N'RECID'
|
||||
WHERE o.xtype = 'U' AND o.name IN (N'SQLDICTIONARY', N'DATAAREA', N'SYSCONFIG')
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>"3.0" or "4.0+" when all three AX system tables are there, else null.</summary>
|
||||
public static string? DynamicsAxVersion(int tables, int? recIdPrecision) =>
|
||||
tables < 3 ? null : recIdPrecision is 10 ? "3.0" : "4.0+";
|
||||
|
||||
public static async Task<List<DiscoveredDatabase>> DiscoverAsync(DbConnection connection, InstanceInfo instance, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentNullException.ThrowIfNull(instance);
|
||||
var databases = await Db.QueryAsync(connection, instance.Version.Major >= 9 ? Databases2005 : Databases2000, r => new DiscoveredDatabase
|
||||
{
|
||||
Name = r.String(0),
|
||||
DatabaseId = r.Int(1),
|
||||
State = r.NullableString(2) ?? "UNKNOWN",
|
||||
RecoveryModel = r.NullableString(3),
|
||||
CompatibilityLevel = r.NullableInt(4),
|
||||
IsReadOnly = r.Bool(5),
|
||||
IsSnapshot = r.Bool(6),
|
||||
HasAccess = r.Bool(7),
|
||||
SizeMb = r.NullableLong(8),
|
||||
IsSystem = DatabaseSelection.IsSystemDatabase(r.String(0)),
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
for (var i = 0; i < databases.Count; i++)
|
||||
{
|
||||
var database = databases[i];
|
||||
if (database.IsSystem || !database.HasAccess || database.State != "ONLINE")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var rows = await Db.QueryAsync(connection, DynamicsAxSql(database.Name, instance.Version), r => (Tables: r.Int(0), Precision: r.NullableInt(1)), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (rows.Count > 0 && DynamicsAxVersion(rows[0].Tables, rows[0].Precision) is { } version)
|
||||
{
|
||||
databases[i] = database with { IsDynamicsAx = true, DynamicsAxVersion = version };
|
||||
}
|
||||
}
|
||||
catch (DbException)
|
||||
{
|
||||
// No access to its metadata after all (e.g. a user missing before 2014): it cannot be told apart.
|
||||
}
|
||||
}
|
||||
|
||||
return databases;
|
||||
}
|
||||
}
|
||||
120
src/SQLInsight.Collectors/Discovery/PermissionCheck.cs
Normal file
120
src/SQLInsight.Collectors/Discovery/PermissionCheck.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
using System.Data.Common;
|
||||
using System.Text;
|
||||
using SQLInsight.Core.Configuration;
|
||||
using SQLInsight.Core.Instances;
|
||||
|
||||
namespace SQLInsight.Collectors.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Which of the rights in db/permissions/grant-least-privilege.sql the agent's login has on an instance, as the login
|
||||
/// itself sees them (HAS_PERMS_BY_NAME, SQL Server 2005+). Only reads; nothing is granted.
|
||||
/// </summary>
|
||||
public static class PermissionCheck
|
||||
{
|
||||
private static readonly string[] MsdbTables =
|
||||
[
|
||||
"backupset", "backupmediafamily", "backupmediaset", "restorehistory", "suspect_pages",
|
||||
"sysjobs", "sysjobsteps", "sysjobhistory", "sysjobactivity", "sysjobschedules", "sysschedules", "syscategories",
|
||||
];
|
||||
|
||||
/// <summary>A right to check: how it is asked for, and what it is for.</summary>
|
||||
internal sealed record Check(string Name, string Expression, bool Required, string Purpose);
|
||||
|
||||
/// <summary>The rights the agent needs on an instance of <paramref name="instance"/>'s version, as the GRANT script gives them.</summary>
|
||||
internal static List<Check> ChecksFor(InstanceInfo instance)
|
||||
{
|
||||
// Azure SQL Managed Instance reports 12.x but runs the newest engine; the script treats it as 2019.
|
||||
var major = instance.IsManagedInstance ? 15 : instance.Version.Major;
|
||||
var checks = new List<Check>();
|
||||
if (major >= 16)
|
||||
{
|
||||
checks.Add(new("VIEW SERVER PERFORMANCE STATE", "HAS_PERMS_BY_NAME(NULL, NULL, N'VIEW SERVER PERFORMANCE STATE')", true,
|
||||
"DMV'er: ventetyper, sessioner, plan cache og I/O."));
|
||||
checks.Add(new("VIEW SERVER SECURITY STATE", "HAS_PERMS_BY_NAME(NULL, NULL, N'VIEW SERVER SECURITY STATE')", true,
|
||||
"Sikkerheds-DMV'er (fx audit og certifikater)."));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new("VIEW SERVER STATE", "HAS_PERMS_BY_NAME(NULL, NULL, N'VIEW SERVER STATE')", true,
|
||||
"DMV'er: ventetyper, sessioner, plan cache og I/O."));
|
||||
}
|
||||
|
||||
checks.Add(new("VIEW ANY DEFINITION", "HAS_PERMS_BY_NAME(NULL, NULL, N'VIEW ANY DEFINITION')", true,
|
||||
"Metadata: indexes, statistikker, filer. Giver ikke adgang til data."));
|
||||
if (major >= 12)
|
||||
{
|
||||
checks.Add(new("CONNECT ANY DATABASE", "HAS_PERMS_BY_NAME(NULL, NULL, N'CONNECT ANY DATABASE')", true,
|
||||
"Åbne alle databaser, også nye, for databasetjek. Giver ikke adgang til data."));
|
||||
}
|
||||
|
||||
foreach (var table in MsdbTables)
|
||||
{
|
||||
checks.Add(new($"SELECT ON msdb.dbo.{table}", $"HAS_PERMS_BY_NAME(N'msdb.dbo.{table}', N'OBJECT', N'SELECT')", true,
|
||||
table.StartsWith("sysjob", StringComparison.Ordinal) || table is "sysschedules" or "syscategories"
|
||||
? "SQL Server Agent-job og deres historik."
|
||||
: table == "suspect_pages" ? "Beskadigede sider (suspect pages)." : "Backup- og restore-historik."));
|
||||
}
|
||||
|
||||
checks.Add(new("ALTER TRACE", "HAS_PERMS_BY_NAME(NULL, NULL, N'ALTER TRACE')", false,
|
||||
"Valgfri: hvem og hvornår ved ændringer af rettigheder, og fejltekster fra fejlloggen (docs/fejllog.md)."));
|
||||
return checks;
|
||||
}
|
||||
|
||||
internal static string BuildSql(IReadOnlyList<Check> checks)
|
||||
{
|
||||
var sql = new StringBuilder();
|
||||
for (var i = 0; i < checks.Count; i++)
|
||||
{
|
||||
sql.Append(i == 0 ? "SELECT " : "\nUNION ALL SELECT ").Append(i).Append(", ").Append(checks[i].Expression);
|
||||
}
|
||||
|
||||
return sql.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Online user databases the login cannot enter: before 2014 it needs a user in each (the GRANT script creates them).</summary>
|
||||
internal const string DatabasesWithoutAccessSql = """
|
||||
SELECT name FROM sys.databases
|
||||
WHERE state = 0 AND database_id > 4 AND is_read_only = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 0
|
||||
ORDER BY name
|
||||
""";
|
||||
|
||||
/// <summary>The login, sysadmin or not, and each right. On SQL Server 2000 the rights cannot be asked for.</summary>
|
||||
public static async Task<ConnectionTestResult> CheckAsync(DbConnection connection, InstanceInfo instance, ConnectionTestResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentNullException.ThrowIfNull(instance);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
var who = (await Db.QueryAsync(connection, "SELECT SUSER_SNAME(), IS_SRVROLEMEMBER('sysadmin')", r => (Login: r.NullableString(0), Sysadmin: r.NullableInt(1) == 1), cancellationToken)
|
||||
.ConfigureAwait(false))[0];
|
||||
result = result with { Login = who.Login, IsSysadmin = who.Sysadmin };
|
||||
var notes = new List<string>(result.Notes);
|
||||
if (who.Sysadmin)
|
||||
{
|
||||
notes.Add("Agentens login er sysadmin. Det virker, men er langt mere, end SQLInsight har brug for. Giv rettighederne med GRANT-scriptet, og fjern sysadmin.");
|
||||
}
|
||||
|
||||
if (instance.Version.Major < 9)
|
||||
{
|
||||
notes.Add("SQL Server 2000 kan ikke vise effektive rettigheder (HAS_PERMS_BY_NAME findes ikke). Se docs/legacy-versions.md for de rettigheder, agenten skal have.");
|
||||
return result with { PermissionsChecked = false, Notes = notes };
|
||||
}
|
||||
|
||||
var checks = ChecksFor(instance);
|
||||
var granted = (await Db.QueryAsync(connection, BuildSql(checks), r => (Index: r.Int(0), Granted: r.NullableInt(1) == 1), cancellationToken).ConfigureAwait(false))
|
||||
.ToDictionary(x => x.Index, x => x.Granted);
|
||||
var permissions = checks.Select((c, i) => new PermissionStatus(c.Name, granted.GetValueOrDefault(i), c.Required, c.Purpose)).ToList();
|
||||
|
||||
var major = instance.IsManagedInstance ? 15 : instance.Version.Major;
|
||||
if (major < 12)
|
||||
{
|
||||
var without = await Db.QueryAsync(connection, DatabasesWithoutAccessSql, r => r.String(0), cancellationToken).ConfigureAwait(false);
|
||||
var examples = string.Join(", ", without.Take(10)) + (without.Count > 10 ? $" og {without.Count - 10} flere" : string.Empty);
|
||||
permissions.Add(new PermissionStatus("Bruger i hver online brugerdatabase", without.Count == 0, true,
|
||||
without.Count == 0
|
||||
? "Før SQL Server 2014 skal agenten have en bruger uden rettigheder i hver database (GRANT-scriptet opretter dem)."
|
||||
: $"Mangler i: {examples}. Kør GRANT-scriptet igen, også når der oprettes nye databaser."));
|
||||
}
|
||||
|
||||
return result with { PermissionsChecked = true, Permissions = permissions, Notes = notes };
|
||||
}
|
||||
}
|
||||
|
|
@ -29,13 +29,19 @@ public interface ICollector
|
|||
}
|
||||
|
||||
/// <param name="Previous">This collector's result from the previous run on the same instance, if any.</param>
|
||||
/// <param name="Databases">The databases chosen for monitoring on the instance; null means all.</param>
|
||||
public sealed record CollectorContext(
|
||||
DbConnection Connection,
|
||||
InstanceInfo Instance,
|
||||
InstanceCapabilities Capabilities,
|
||||
DateTimeOffset Now,
|
||||
object? Previous,
|
||||
CancellationToken CancellationToken);
|
||||
CancellationToken CancellationToken,
|
||||
Core.Configuration.DatabaseSelection? Databases = null)
|
||||
{
|
||||
/// <summary>Whether the database-specific checks run in <paramref name="database"/>.</summary>
|
||||
public bool IncludesDatabase(string database) => Databases?.Includes(database) ?? true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed base class for collectors.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ public sealed class QueryStoreCollector : Collector<QueryStoreSnapshot>
|
|||
protected override async Task<QueryStoreSnapshot> CollectAsync(CollectorContext context, QueryStoreSnapshot? previous)
|
||||
{
|
||||
var ct = context.CancellationToken;
|
||||
var names = await Db.QueryAsync(context.Connection, DatabasesSql, r => r.String(0), ct).ConfigureAwait(false);
|
||||
var names = (await Db.QueryAsync(context.Connection, DatabasesSql, r => r.String(0), ct).ConfigureAwait(false)).Where(context.IncludesDatabase).ToList();
|
||||
var databases = new List<QueryStoreDatabase>(names.Count);
|
||||
foreach (var name in names)
|
||||
{
|
||||
|
|
|
|||
99
tests/SQLInsight.Collectors.Tests/DiscoveryTests.cs
Normal file
99
tests/SQLInsight.Collectors.Tests/DiscoveryTests.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using SQLInsight.Collectors.Discovery;
|
||||
using SQLInsight.Core.Instances;
|
||||
|
||||
namespace SQLInsight.Collectors.Tests;
|
||||
|
||||
/// <summary>Database discovery and the permission check: version-guarded SQL and what is asked for on each version.</summary>
|
||||
public sealed class DiscoveryTests
|
||||
{
|
||||
private static InstanceInfo Instance(string version, EngineEdition edition = EngineEdition.Standard) => new()
|
||||
{
|
||||
ServerName = "SQL01", Version = SqlServerVersion.Parse(version), Edition = "Standard Edition", EngineEdition = edition,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(3, 10, "3.0")]
|
||||
[InlineData(3, 19, "4.0+")]
|
||||
[InlineData(3, null, "4.0+")]
|
||||
[InlineData(2, 10, null)]
|
||||
[InlineData(0, null, null)]
|
||||
public void Dynamics_ax_is_recognised_by_its_three_system_tables_and_the_recid_type(int tables, int? precision, string? expected) =>
|
||||
Assert.Equal(expected, DatabaseDiscovery.DynamicsAxVersion(tables, precision));
|
||||
|
||||
[Fact]
|
||||
public void Dynamics_ax_detection_uses_three_part_names_and_catalog_views_by_version()
|
||||
{
|
||||
var modern = DatabaseDiscovery.DynamicsAxSql("AX]30", SqlServerVersion.Parse("9.0.5000.0"));
|
||||
var legacy = DatabaseDiscovery.DynamicsAxSql("AX30", SqlServerVersion.Parse("8.0.2039.0"));
|
||||
|
||||
Assert.Contains("[AX]]30].sys.tables", modern, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("USE ", modern, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("[AX30].dbo.sysobjects", legacy, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("sys.", legacy, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sql_server_2008_r2_is_checked_for_view_server_state_and_a_user_in_each_database()
|
||||
{
|
||||
var checks = PermissionCheck.ChecksFor(Instance("10.50.6000.34")).Select(c => c.Name).ToList();
|
||||
|
||||
Assert.Contains("VIEW SERVER STATE", checks);
|
||||
Assert.Contains("VIEW ANY DEFINITION", checks);
|
||||
Assert.DoesNotContain("CONNECT ANY DATABASE", checks);
|
||||
Assert.DoesNotContain("VIEW SERVER PERFORMANCE STATE", checks);
|
||||
Assert.Contains("SELECT ON msdb.dbo.backupset", checks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sql_server_2022_is_checked_for_the_granular_rights_and_managed_instance_like_2019()
|
||||
{
|
||||
var sql2022 = PermissionCheck.ChecksFor(Instance("16.0.4135.4")).Select(c => c.Name).ToList();
|
||||
var managed = PermissionCheck.ChecksFor(Instance("12.0.2000.8", EngineEdition.AzureSqlManagedInstance)).Select(c => c.Name).ToList();
|
||||
|
||||
Assert.Contains("VIEW SERVER PERFORMANCE STATE", sql2022);
|
||||
Assert.Contains("VIEW SERVER SECURITY STATE", sql2022);
|
||||
Assert.DoesNotContain("VIEW SERVER STATE", sql2022);
|
||||
Assert.Contains("CONNECT ANY DATABASE", sql2022);
|
||||
Assert.Contains("VIEW SERVER STATE", managed);
|
||||
Assert.Contains("CONNECT ANY DATABASE", managed);
|
||||
Assert.False(PermissionCheck.ChecksFor(Instance("16.0.4135.4")).Single(c => c.Name == "ALTER TRACE").Required);
|
||||
}
|
||||
|
||||
/// <summary>2008+ constructs that make a batch fail on 2005 (as in Sql2005CompatibilityTests).</summary>
|
||||
private static readonly Regex[] Sql2008Only =
|
||||
[
|
||||
new(@"DECLARE\s+@\w+\s+\w+(\s*\([\d\s,]+\))?\s*=", RegexOptions.IgnoreCase),
|
||||
new(@"\bMERGE\s", RegexOptions.IgnoreCase),
|
||||
new(@"\b(CONCAT|IIF|CHOOSE|FORMAT|TRY_CONVERT|TRY_CAST|STRING_AGG|STRING_SPLIT)\s*\(", RegexOptions.IgnoreCase),
|
||||
new(@"\bOFFSET\s+\S+\s+ROWS\b", RegexOptions.IgnoreCase),
|
||||
new(@"\bVALUES\s*\([^)]*\)\s*,\s*\(", RegexOptions.IgnoreCase),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void Discovery_and_permission_sql_runs_on_sql_server_2005()
|
||||
{
|
||||
var sql2005 = Instance("9.0.5000.0");
|
||||
var batches = new[]
|
||||
{
|
||||
DatabaseDiscovery.Databases2005, DatabaseDiscovery.Databases2000, DatabaseDiscovery.DynamicsAxSql("AX30", sql2005.Version),
|
||||
PermissionCheck.BuildSql(PermissionCheck.ChecksFor(sql2005)), PermissionCheck.DatabasesWithoutAccessSql,
|
||||
};
|
||||
|
||||
Assert.All(batches, sql => Assert.DoesNotContain(Sql2008Only, p => p.IsMatch(sql)));
|
||||
Assert.All(batches, sql => Assert.DoesNotContain("PXI_BATCHMONITORTABLE", sql, StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain("sys.", DatabaseDiscovery.Databases2000, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Collector_context_includes_every_database_without_a_selection()
|
||||
{
|
||||
var context = new CollectorContext(null!, Instance("16.0.4135.4"), InstanceCapabilities.From(Instance("16.0.4135.4")), DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
var chosen = context with { Databases = new Core.Configuration.DatabaseSelection(false, ["Sales"], [], []) };
|
||||
|
||||
Assert.True(context.IncludesDatabase("Anything"));
|
||||
Assert.True(chosen.IncludesDatabase("Sales"));
|
||||
Assert.True(chosen.IncludesDatabase("msdb"));
|
||||
Assert.False(chosen.IncludesDatabase("Anything"));
|
||||
}
|
||||
}
|
||||
|
|
@ -36,10 +36,10 @@ internal sealed class AgentSession : IAsyncDisposable
|
|||
return new AgentSession(opened, instance);
|
||||
}
|
||||
|
||||
public async Task<T> CollectAsync<T>(ICollector collector, T? previous = null)
|
||||
public async Task<T> CollectAsync<T>(ICollector collector, T? previous = null, Core.Configuration.DatabaseSelection? selection = null)
|
||||
where T : class
|
||||
{
|
||||
var context = new CollectorContext(_opened.Connection, Instance, Capabilities, DateTimeOffset.UtcNow, previous, CancellationToken.None);
|
||||
var context = new CollectorContext(_opened.Connection, Instance, Capabilities, DateTimeOffset.UtcNow, previous, CancellationToken.None, selection);
|
||||
return (T)await collector.CollectAsync(context);
|
||||
}
|
||||
|
||||
|
|
|
|||
102
tests/SQLInsight.IntegrationTests/DiscoveryTests.cs
Normal file
102
tests/SQLInsight.IntegrationTests/DiscoveryTests.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using SQLInsight.Collectors.Databases;
|
||||
using SQLInsight.Collectors.Discovery;
|
||||
using SQLInsight.Connectivity;
|
||||
using SQLInsight.Core.Configuration;
|
||||
using SQLInsight.Core.Data;
|
||||
using SQLInsight.Core.Instances;
|
||||
|
||||
namespace SQLInsight.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Database discovery, the permission check and the database selection against a real instance, as the
|
||||
/// least-privilege agent login (and as a login without any rights).
|
||||
/// </summary>
|
||||
[Collection(SqlServerTestGroup.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
public class DiscoveryTests(SqlServerFixture sql) : IAsyncLifetime
|
||||
{
|
||||
private readonly string _axDatabase = "AX30_" + Guid.NewGuid().ToString("N")[..6];
|
||||
|
||||
public Task InitializeAsync() => sql.ExecuteAsSaAsync($"""
|
||||
CREATE DATABASE [{_axDatabase}];
|
||||
EXEC (N'USE [{_axDatabase}]; EXEC (N''CREATE SCHEMA bmssa'');
|
||||
CREATE TABLE bmssa.SQLDICTIONARY (TABLEID int NOT NULL, FIELDID int NOT NULL, RECID int NOT NULL);
|
||||
CREATE TABLE bmssa.DATAAREA (ID nvarchar(3) NOT NULL, RECID int NOT NULL);
|
||||
CREATE TABLE bmssa.SYSCONFIG (ID int NOT NULL, RECID int NOT NULL);');
|
||||
""");
|
||||
|
||||
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_axDatabase}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_axDatabase}];");
|
||||
|
||||
private static ConnectionTarget Target(string server, string login, string password) => new()
|
||||
{
|
||||
Server = server, Authentication = AuthenticationMode.SqlLogin, UserName = login, Password = password,
|
||||
Provider = ConnectionProvider.SqlClient, TrustServerCertificate = true,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_login_sees_every_database_with_its_state_and_recognises_dynamics_ax()
|
||||
{
|
||||
await using var opened = await new SqlConnectionFactory().OpenAsync(Target(sql.Server, SqlServerFixture.AgentLogin, SqlServerFixture.AgentPassword));
|
||||
var info = await InstanceProbe.DetectAsync(opened.Connection);
|
||||
|
||||
var databases = (await DatabaseDiscovery.DiscoverAsync(opened.Connection, info, CancellationToken.None)).ToDictionary(d => d.Name);
|
||||
|
||||
Assert.True(databases["master"].IsSystem);
|
||||
var test = databases[SqlServerFixture.TestDatabase];
|
||||
Assert.Equal(("ONLINE", true, false), (test.State, test.HasAccess, test.IsDynamicsAx));
|
||||
Assert.True(test.SizeMb > 0);
|
||||
Assert.NotNull(test.RecoveryModel);
|
||||
Assert.True(test.CompatibilityLevel >= 90);
|
||||
Assert.Equal((true, "3.0"), (databases[_axDatabase].IsDynamicsAx, databases[_axDatabase].DynamicsAxVersion));
|
||||
Assert.Equal("master", (await SingleAsync(opened, "SELECT DB_NAME()"))); // the session never changed database
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_login_has_every_required_right_and_a_login_without_rights_is_told_what_is_missing()
|
||||
{
|
||||
await using (var agent = await new SqlConnectionFactory().OpenAsync(Target(sql.Server, SqlServerFixture.AgentLogin, SqlServerFixture.AgentPassword)))
|
||||
{
|
||||
var info = await InstanceProbe.DetectAsync(agent.Connection);
|
||||
var result = await PermissionCheck.CheckAsync(agent.Connection, info, new ConnectionTestResult { Connected = true }, CancellationToken.None);
|
||||
|
||||
Assert.Equal((SqlServerFixture.AgentLogin, false, true), (result.Login, result.IsSysadmin, result.PermissionsChecked));
|
||||
Assert.Empty(result.MissingRequired);
|
||||
Assert.Contains(result.Permissions, p => p.Name == "ALTER TRACE" && !p.Granted && !p.Required);
|
||||
}
|
||||
|
||||
var bare = "bare_" + Guid.NewGuid().ToString("N")[..8];
|
||||
await sql.ExecuteAsSaAsync($"CREATE LOGIN [{bare}] WITH PASSWORD = N'Bare-Login-1!', CHECK_POLICY = OFF;");
|
||||
await using var opened = await new SqlConnectionFactory().OpenAsync(Target(sql.Server, bare, "Bare-Login-1!"));
|
||||
var bareInfo = await InstanceProbe.DetectAsync(opened.Connection);
|
||||
|
||||
var missing = (await PermissionCheck.CheckAsync(opened.Connection, bareInfo, new ConnectionTestResult { Connected = true }, CancellationToken.None))
|
||||
.MissingRequired.Select(p => p.Name).ToList();
|
||||
|
||||
Assert.Contains("VIEW ANY DEFINITION", missing);
|
||||
Assert.Contains("CONNECT ANY DATABASE", missing);
|
||||
Assert.Contains("SELECT ON msdb.dbo.sysjobs", missing); // backupset is readable by public in msdb
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Database_checks_only_run_in_the_chosen_databases()
|
||||
{
|
||||
await using var session = await AgentSession.OpenAsync(sql);
|
||||
var selection = new DatabaseSelection(monitorNew: false, [_axDatabase], [SqlServerFixture.TestDatabase], []);
|
||||
|
||||
var snapshot = await session.CollectAsync<DatabaseSnapshot>(new DatabaseCollector(), selection: selection);
|
||||
var indexes = await session.CollectAsync<IndexSnapshot>(new IndexCollector(), selection: selection);
|
||||
|
||||
var names = snapshot.Databases.Select(d => d.Name).ToList();
|
||||
Assert.Contains(_axDatabase, names);
|
||||
Assert.Contains("msdb", names);
|
||||
Assert.DoesNotContain(SqlServerFixture.TestDatabase, names);
|
||||
Assert.DoesNotContain(indexes.Databases, d => d.Name == SqlServerFixture.TestDatabase);
|
||||
}
|
||||
|
||||
private static async Task<string> SingleAsync(OpenedConnection opened, string query)
|
||||
{
|
||||
await using var command = opened.Connection.CreateCommand();
|
||||
command.CommandText = query;
|
||||
return (string)(await command.ExecuteScalarAsync())!;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue