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())!; + } +}