From b2fa78411f767ef225510142d7dcccbb07db27ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Fri, 2 Oct 2026 11:44:21 +0200 Subject: [PATCH] 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) --- .../Custom/CustomQueryCollector.cs | 3 +- .../Databases/DatabaseCollector.cs | 3 + .../Databases/IndexCollector.cs | 2 +- .../Discovery/DatabaseDiscovery.cs | 110 ++++++++++++++++ .../Discovery/PermissionCheck.cs | 120 ++++++++++++++++++ src/SQLInsight.Collectors/ICollector.cs | 8 +- .../Performance/QueryStoreCollector.cs | 2 +- .../DiscoveryTests.cs | 99 +++++++++++++++ .../AgentSession.cs | 4 +- .../DiscoveryTests.cs | 102 +++++++++++++++ 10 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 src/SQLInsight.Collectors/Discovery/DatabaseDiscovery.cs create mode 100644 src/SQLInsight.Collectors/Discovery/PermissionCheck.cs create mode 100644 tests/SQLInsight.Collectors.Tests/DiscoveryTests.cs create mode 100644 tests/SQLInsight.IntegrationTests/DiscoveryTests.cs diff --git a/src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs b/src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs index a0d7c17..068baaa 100644 --- a/src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs +++ b/src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs @@ -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); } } diff --git a/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs b/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs index db00f8d..2332bd8 100644 --- a/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs +++ b/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs @@ -46,6 +46,9 @@ public sealed class DatabaseCollector : Collector 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; diff --git a/src/SQLInsight.Collectors/Databases/IndexCollector.cs b/src/SQLInsight.Collectors/Databases/IndexCollector.cs index 632ef6f..db4e1b4 100644 --- a/src/SQLInsight.Collectors/Databases/IndexCollector.cs +++ b/src/SQLInsight.Collectors/Databases/IndexCollector.cs @@ -33,7 +33,7 @@ public sealed class IndexCollector : Collector 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), diff --git a/src/SQLInsight.Collectors/Discovery/DatabaseDiscovery.cs b/src/SQLInsight.Collectors/Discovery/DatabaseDiscovery.cs new file mode 100644 index 0000000..bf29dbf --- /dev/null +++ b/src/SQLInsight.Collectors/Discovery/DatabaseDiscovery.cs @@ -0,0 +1,110 @@ +using System.Data.Common; +using SQLInsight.Core.Configuration; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Discovery; + +/// +/// 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. +/// +public static class DatabaseDiscovery +{ + /// SQL Server 2005 and later. + 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 + """; + + /// SQL Server 2000: no sys.databases; status and recovery model from DATABASEPROPERTYEX. + 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 + """; + + /// + /// 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. + /// + 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') + """; + } + + /// "3.0" or "4.0+" when all three AX system tables are there, else null. + public static string? DynamicsAxVersion(int tables, int? recIdPrecision) => + tables < 3 ? null : recIdPrecision is 10 ? "3.0" : "4.0+"; + + public static async Task> 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; + } +} diff --git a/src/SQLInsight.Collectors/Discovery/PermissionCheck.cs b/src/SQLInsight.Collectors/Discovery/PermissionCheck.cs new file mode 100644 index 0000000..8cbf8a6 --- /dev/null +++ b/src/SQLInsight.Collectors/Discovery/PermissionCheck.cs @@ -0,0 +1,120 @@ +using System.Data.Common; +using System.Text; +using SQLInsight.Core.Configuration; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Discovery; + +/// +/// 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. +/// +public static class PermissionCheck +{ + private static readonly string[] MsdbTables = + [ + "backupset", "backupmediafamily", "backupmediaset", "restorehistory", "suspect_pages", + "sysjobs", "sysjobsteps", "sysjobhistory", "sysjobactivity", "sysjobschedules", "sysschedules", "syscategories", + ]; + + /// A right to check: how it is asked for, and what it is for. + internal sealed record Check(string Name, string Expression, bool Required, string Purpose); + + /// The rights the agent needs on an instance of 's version, as the GRANT script gives them. + internal static List 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(); + 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 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(); + } + + /// Online user databases the login cannot enter: before 2014 it needs a user in each (the GRANT script creates them). + 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 + """; + + /// The login, sysadmin or not, and each right. On SQL Server 2000 the rights cannot be asked for. + public static async Task 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(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 }; + } +} diff --git a/src/SQLInsight.Collectors/ICollector.cs b/src/SQLInsight.Collectors/ICollector.cs index 9205ef4..c78b175 100644 --- a/src/SQLInsight.Collectors/ICollector.cs +++ b/src/SQLInsight.Collectors/ICollector.cs @@ -29,13 +29,19 @@ public interface ICollector } /// This collector's result from the previous run on the same instance, if any. +/// The databases chosen for monitoring on the instance; null means all. public sealed record CollectorContext( DbConnection Connection, InstanceInfo Instance, InstanceCapabilities Capabilities, DateTimeOffset Now, object? Previous, - CancellationToken CancellationToken); + CancellationToken CancellationToken, + Core.Configuration.DatabaseSelection? Databases = null) +{ + /// Whether the database-specific checks run in . + public bool IncludesDatabase(string database) => Databases?.Includes(database) ?? true; +} /// /// Typed base class for collectors. diff --git a/src/SQLInsight.Collectors/Performance/QueryStoreCollector.cs b/src/SQLInsight.Collectors/Performance/QueryStoreCollector.cs index e0d7166..a2099b6 100644 --- a/src/SQLInsight.Collectors/Performance/QueryStoreCollector.cs +++ b/src/SQLInsight.Collectors/Performance/QueryStoreCollector.cs @@ -24,7 +24,7 @@ public sealed class QueryStoreCollector : Collector protected override async Task 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(names.Count); foreach (var name in names) { diff --git a/tests/SQLInsight.Collectors.Tests/DiscoveryTests.cs b/tests/SQLInsight.Collectors.Tests/DiscoveryTests.cs new file mode 100644 index 0000000..f302954 --- /dev/null +++ b/tests/SQLInsight.Collectors.Tests/DiscoveryTests.cs @@ -0,0 +1,99 @@ +using System.Text.RegularExpressions; +using SQLInsight.Collectors.Discovery; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Tests; + +/// Database discovery and the permission check: version-guarded SQL and what is asked for on each version. +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); + } + + /// 2008+ constructs that make a batch fail on 2005 (as in Sql2005CompatibilityTests). + 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")); + } +} diff --git a/tests/SQLInsight.IntegrationTests/AgentSession.cs b/tests/SQLInsight.IntegrationTests/AgentSession.cs index 1d45614..b76c5df 100644 --- a/tests/SQLInsight.IntegrationTests/AgentSession.cs +++ b/tests/SQLInsight.IntegrationTests/AgentSession.cs @@ -36,10 +36,10 @@ internal sealed class AgentSession : IAsyncDisposable return new AgentSession(opened, instance); } - public async Task CollectAsync(ICollector collector, T? previous = null) + public async Task CollectAsync(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); } diff --git a/tests/SQLInsight.IntegrationTests/DiscoveryTests.cs b/tests/SQLInsight.IntegrationTests/DiscoveryTests.cs new file mode 100644 index 0000000..83d9be2 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/DiscoveryTests.cs @@ -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; + +/// +/// 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). +/// +[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(new DatabaseCollector(), selection: selection); + var indexes = await session.CollectAsync(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 SingleAsync(OpenedConnection opened, string query) + { + await using var command = opened.Connection.CreateCommand(); + command.CommandText = query; + return (string)(await command.ExecuteScalarAsync())!; + } +}