sqlinsight-pro/tests/SQLInsight.IntegrationTests/LeastPrivilegeTests.cs
Claude f8aa0c307b
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Fase 0: solution structure, capability model, rule engine, least-privilege script
- .NET 10 solution with Core, Connectivity, Rules, Contracts, Agent, Web and Hub
- Capability model gating collectors and rules by SQL Server version (2000 and later);
  rules that cannot run are reported as not measurable, never as passed
- Connectivity via Microsoft.Data.SqlClient with ODBC fallback for legacy instances,
  hardened monitoring session settings valid back to SQL Server 2000
- Support lifecycle rule (SQLI-VER-001) with lifecycle data shipped as a rule pack
- Version-aware least-privilege permissions script, verified by integration tests
  against SQL Server in Docker (reads monitoring sources, cannot read customer data)
- Forgejo Actions CI, contributing guide, code of conduct, issue templates,
  legacy version notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
2026-09-29 05:40:49 +00:00

107 lines
4.7 KiB
C#

using Microsoft.Data.SqlClient;
namespace SQLInsight.IntegrationTests;
/// <summary>
/// Runs db/permissions/grant-least-privilege.sql and proves that the agent login can read everything it needs,
/// and nothing else.
/// </summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class LeastPrivilegeTests(SqlServerFixture sql) : IAsyncLifetime
{
private const string Login = "sqlinsight_agent";
private const string Password = "Agent-Pa55word!";
private const string Database = "SQLInsightQsTest";
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"""
IF SUSER_ID(N'{Login}') IS NULL CREATE LOGIN [{Login}] WITH PASSWORD = N'{Password}', CHECK_POLICY = OFF;
IF DB_ID(N'{Database}') IS NULL CREATE DATABASE [{Database}];
""");
await sql.ExecuteAsSaAsync($"""
USE [{Database}];
ALTER DATABASE [{Database}] SET QUERY_STORE = ON;
IF OBJECT_ID(N'dbo.CustomerData') IS NULL
BEGIN
CREATE TABLE dbo.CustomerData (Id int NOT NULL PRIMARY KEY, Secret nvarchar(50) NOT NULL);
INSERT dbo.CustomerData VALUES (1, N'fortroligt');
END;
""");
var script = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Scripts", "grant-least-privilege.sql"));
script = script.Replace(@"N'DOMÆNE\svc-sqlinsight$'", $"N'{Login}'", StringComparison.Ordinal);
await sql.ExecuteAsSaAsync(script);
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Agent_login_is_not_sysadmin()
{
Assert.Equal(0, await ScalarAsync<int>("SELECT IS_SRVROLEMEMBER('sysadmin')"));
}
[Theory]
[InlineData("SELECT COUNT(*) FROM sys.dm_os_wait_stats")]
[InlineData("SELECT COUNT(*) FROM sys.dm_exec_requests r CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t")]
[InlineData("SELECT COUNT(*) FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) p")]
[InlineData("SELECT COUNT(*) FROM sys.dm_io_virtual_file_stats(NULL, NULL)")]
[InlineData("SELECT COUNT(*) FROM sys.dm_os_ring_buffers")]
[InlineData("SELECT COUNT(*) FROM sys.dm_xe_sessions WHERE name = 'system_health'")]
[InlineData("SELECT COUNT(*) FROM sys.configurations")]
[InlineData("SELECT COUNT(*) FROM msdb.dbo.backupset")]
[InlineData("SELECT COUNT(*) FROM msdb.dbo.sysjobs")]
[InlineData("SELECT COUNT(*) FROM msdb.dbo.sysjobhistory")]
[InlineData("SELECT COUNT(*) FROM msdb.dbo.suspect_pages")]
[InlineData($"SELECT COUNT(*) FROM [{Database}].sys.database_query_store_options")]
[InlineData($"SELECT COUNT(*) FROM [{Database}].sys.query_store_runtime_stats")]
[InlineData($"SELECT COUNT(*) FROM [{Database}].sys.indexes WHERE object_id = OBJECT_ID(N'[{Database}].dbo.CustomerData')")]
[InlineData($"SELECT COUNT(*) FROM [{Database}].sys.dm_db_index_usage_stats")]
[InlineData($"SELECT COUNT(*) WHERE DATABASEPROPERTYEX(N'{Database}', 'LastGoodCheckDbTime') IS NOT NULL")]
public async Task Agent_can_read_monitoring_sources(string query)
{
Assert.True(await ScalarAsync<int>(query) >= 0);
}
[Fact]
public async Task Agent_cannot_read_customer_data()
{
var ex = await Assert.ThrowsAsync<SqlException>(
() => ScalarAsync<int>($"SELECT COUNT(*) FROM [{Database}].dbo.CustomerData"));
Assert.Equal(229, ex.Number); // The SELECT permission was denied.
}
[Fact]
public async Task Agent_cannot_change_anything()
{
var ex = await Assert.ThrowsAsync<SqlException>(
() => ScalarAsync<int>($"USE [{Database}]; CREATE TABLE dbo.ShouldNotExist (Id int); SELECT 1"));
Assert.Equal(262, ex.Number); // CREATE TABLE permission denied.
}
[Fact]
public async Task Script_is_idempotent()
{
var script = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Scripts", "grant-least-privilege.sql"));
script = script.Replace(@"N'DOMÆNE\svc-sqlinsight$'", $"N'{Login}'", StringComparison.Ordinal);
await sql.ExecuteAsSaAsync(script);
}
private async Task<T> ScalarAsync<T>(string query)
{
var builder = new SqlConnectionStringBuilder(sql.SaConnectionString)
{
UserID = Login,
Password = Password,
InitialCatalog = "master",
};
await using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = query;
return (T)Convert.ChangeType((await command.ExecuteScalarAsync())!, typeof(T), System.Globalization.CultureInfo.InvariantCulture);
}
}