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
- .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
107 lines
4.7 KiB
C#
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);
|
|
}
|
|
}
|