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>
102 lines
5.3 KiB
C#
102 lines
5.3 KiB
C#
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())!;
|
|
}
|
|
}
|