diff --git a/src/SQLInsight.Core/Configuration/AgentRequests.cs b/src/SQLInsight.Core/Configuration/AgentRequests.cs
new file mode 100644
index 0000000..3e4c5fe
--- /dev/null
+++ b/src/SQLInsight.Core/Configuration/AgentRequests.cs
@@ -0,0 +1,150 @@
+using System.Text.Json;
+
+namespace SQLInsight.Core.Configuration;
+
+///
+/// What SQLInsight Web asks the agent to do (insight.AgentRequests). Connection tests and discovery run in the agent,
+/// as the agent's identity, because that is what connects to the instances in production; the web UI only queues the
+/// request and shows the result.
+///
+public static class AgentRequestKind
+{
+ /// Connect to a configured instance and check the agent's rights there.
+ public const string ConnectionTest = "ConnectionTest";
+
+ /// List the databases on a configured instance (state, size, recovery model, Dynamics AX …).
+ public const string DatabaseDiscovery = "DatabaseDiscovery";
+
+ /// Find SQL Server instances on a computer (SQL Server Browser, and the registry on the agent's own computer).
+ public const string InstanceDiscovery = "InstanceDiscovery";
+
+ public static readonly IReadOnlyList All = [ConnectionTest, DatabaseDiscovery, InstanceDiscovery];
+}
+
+public static class AgentRequestStatus
+{
+ public const string Pending = "Pending";
+ public const string Running = "Running";
+ public const string Done = "Done";
+ public const string Failed = "Failed";
+}
+
+/// JSON of request parameters and results, shared by the agent (writes) and the web UI and API (reads).
+public static class AgentRequestJson
+{
+ public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
+
+ public static string Serialize(T value) => JsonSerializer.Serialize(value, Options);
+
+ public static T? Deserialize(string? json)
+ where T : class
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return null;
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize(json, Options);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+}
+
+/// Parameters of an .
+/// The computer to ask; empty for the agent's own computer.
+public sealed record InstanceDiscoveryParameters(string? Host);
+
+/// One right the agent needs (or may have) on an instance, and whether it has it.
+/// The permission as written in GRANT, e.g. VIEW SERVER STATE, or SELECT ON msdb.dbo.backupset.
+/// False for optional rights (ALTER TRACE): missing them limits a feature, not the monitoring.
+/// What SQLInsight uses it for (Danish).
+public sealed record PermissionStatus(string Name, bool Granted, bool Required, string Purpose);
+
+/// The outcome of a connection test, as the agent saw it.
+public sealed record ConnectionTestResult
+{
+ public bool Connected { get; init; }
+ public string? Error { get; init; }
+
+ /// What to do about (Danish).
+ public string? Hint { get; init; }
+
+ public string? Provider { get; init; }
+ public bool UsedLegacyFallback { get; init; }
+ public string? ProductVersion { get; init; }
+ public string? Release { get; init; }
+ public string? Edition { get; init; }
+ public string? ServerName { get; init; }
+
+ /// The login the agent arrived as (SUSER_SNAME()): the one the GRANT script is for.
+ public string? Login { get; init; }
+
+ /// The agent is sysadmin on the instance. It works, but it is more than it needs.
+ public bool IsSysadmin { get; init; }
+
+ /// False on SQL Server 2000, which cannot report effective permissions (no HAS_PERMS_BY_NAME).
+ public bool PermissionsChecked { get; init; }
+
+ public IReadOnlyList Permissions { get; init; } = [];
+
+ /// Notes about the result, e.g. why permissions were not checked (Danish).
+ public IReadOnlyList Notes { get; init; } = [];
+
+ public IEnumerable MissingRequired => Permissions.Where(p => p.Required && !p.Granted);
+}
+
+/// A database as the agent found it on an instance.
+public sealed record DiscoveredDatabase
+{
+ public required string Name { get; init; }
+ public int DatabaseId { get; init; }
+ public string State { get; init; } = "ONLINE";
+ public long? SizeMb { get; init; }
+ public string? RecoveryModel { get; init; }
+ public int? CompatibilityLevel { get; init; }
+ public bool IsReadOnly { get; init; }
+ public bool IsSystem { get; init; }
+
+ /// A database snapshot (not monitored on its own).
+ public bool IsSnapshot { get; init; }
+
+ /// The agent can enter the database (HAS_DBACCESS); without it, database-specific checks cannot run there.
+ public bool HasAccess { get; init; }
+
+ /// The Dynamics AX system tables (SQLDICTIONARY, DATAAREA, SYSCONFIG) are there.
+ public bool IsDynamicsAx { get; init; }
+
+ /// "3.0" (32-bit RECID) or "4.0+" (64-bit RECID), when .
+ public string? DynamicsAxVersion { get; init; }
+}
+
+/// Summary of a database discovery; the databases themselves are in insight.ConfiguredDatabases.
+public sealed record DatabaseDiscoveryResult
+{
+ public int Databases { get; init; }
+ public IReadOnlyList New { get; init; } = [];
+ public IReadOnlyList Gone { get; init; } = [];
+
+ /// New databases were monitored at once (the instance monitors new databases automatically).
+ public bool NewMonitored { get; init; }
+
+ public string? Error { get; init; }
+ public string? Hint { get; init; }
+}
+
+/// A SQL Server instance found on a computer.
+/// What to type as the server: HOST or HOST\INSTANCE.
+/// Where it was found (Danish): "SQL Server Browser" or "registreringsdatabasen".
+public sealed record DiscoveredInstance(string Server, string InstanceName, string? Version, int? TcpPort, bool IsClustered, string Source);
+
+public sealed record InstanceDiscoveryResult
+{
+ public string Host { get; init; } = string.Empty;
+ public IReadOnlyList Instances { get; init; } = [];
+ public IReadOnlyList Notes { get; init; } = [];
+}
diff --git a/src/SQLInsight.Core/Configuration/CredentialEnvelope.cs b/src/SQLInsight.Core/Configuration/CredentialEnvelope.cs
new file mode 100644
index 0000000..2a037e7
--- /dev/null
+++ b/src/SQLInsight.Core/Configuration/CredentialEnvelope.cs
@@ -0,0 +1,160 @@
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace SQLInsight.Core.Configuration;
+
+///
+/// A SQL login password at rest in the repository, readable only by the agent.
+///
+/// The agent has an RSA key pair. The private key never leaves the agent's computer (protected with DPAPI
+/// LocalMachine on Windows); the public key is published in the repository (insight.AgentKeys). SQLInsight Web seals a
+/// password with the public key and can never open it again, so neither the web UI, the API nor anyone who can read the
+/// repository can get it back. Each password gets its own AES-256-GCM key, wrapped with RSA-OAEP-SHA256.
+///
+///
+/// The envelope is bound to the server and login it was entered for (): moving the ciphertext to
+/// another row, or pointing the row at another server, makes it unreadable, so the agent never sends a password to a
+/// server it was not entered for.
+///
+/// Format: version (1) · key id length (1) · key id (UTF-8) · wrapped key length (2, big-endian) · wrapped key · nonce (12) · tag (16) · ciphertext.
+///
+public static class CredentialEnvelope
+{
+ public const byte FormatVersion = 1;
+ private const int NonceSize = 12;
+ private const int TagSize = 16;
+ private const int KeySize = 32;
+
+ /// What a password is bound to: the server (with port) and the login it was entered for.
+ public static string Context(string server, string? userName) =>
+ "sqlinsight-credential-v1\n" + (server ?? string.Empty).Trim().ToUpperInvariant() + "\n" + (userName ?? string.Empty).Trim().ToUpperInvariant();
+
+ /// Seals for the agent whose public key (SubjectPublicKeyInfo, DER) is given.
+ public static byte[] Seal(string secret, string keyId, byte[] publicKey, string context)
+ {
+ ArgumentNullException.ThrowIfNull(secret);
+ ArgumentException.ThrowIfNullOrEmpty(keyId);
+ ArgumentNullException.ThrowIfNull(publicKey);
+ ArgumentNullException.ThrowIfNull(context);
+ var keyIdBytes = Encoding.UTF8.GetBytes(keyId);
+ if (keyIdBytes.Length > byte.MaxValue)
+ {
+ throw new ArgumentException("Nøgle-id'et er for langt.", nameof(keyId));
+ }
+
+ using var rsa = RSA.Create();
+ rsa.ImportSubjectPublicKeyInfo(publicKey, out _);
+ var key = RandomNumberGenerator.GetBytes(KeySize);
+ try
+ {
+ var wrapped = rsa.Encrypt(key, RSAEncryptionPadding.OaepSHA256);
+ var nonce = RandomNumberGenerator.GetBytes(NonceSize);
+ var plaintext = Encoding.UTF8.GetBytes(secret);
+ var ciphertext = new byte[plaintext.Length];
+ var tag = new byte[TagSize];
+ using (var aes = new AesGcm(key, TagSize))
+ {
+ aes.Encrypt(nonce, plaintext, ciphertext, tag, AssociatedData(keyId, context));
+ }
+
+ CryptographicOperations.ZeroMemory(plaintext);
+ var envelope = new byte[1 + 1 + keyIdBytes.Length + 2 + wrapped.Length + NonceSize + TagSize + ciphertext.Length];
+ var at = 0;
+ envelope[at++] = FormatVersion;
+ envelope[at++] = (byte)keyIdBytes.Length;
+ keyIdBytes.CopyTo(envelope, at);
+ at += keyIdBytes.Length;
+ BinaryPrimitives.WriteUInt16BigEndian(envelope.AsSpan(at), (ushort)wrapped.Length);
+ at += 2;
+ wrapped.CopyTo(envelope, at);
+ at += wrapped.Length;
+ nonce.CopyTo(envelope, at);
+ at += NonceSize;
+ tag.CopyTo(envelope, at);
+ at += TagSize;
+ ciphertext.CopyTo(envelope, at);
+ return envelope;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(key);
+ }
+ }
+
+ /// The id of the agent key an envelope was sealed for, or null if it is not an envelope.
+ public static string? KeyIdOf(byte[]? envelope)
+ {
+ if (envelope is null || envelope.Length < 2 || envelope[0] != FormatVersion || envelope.Length < 2 + envelope[1])
+ {
+ return null;
+ }
+
+ return Encoding.UTF8.GetString(envelope, 2, envelope[1]);
+ }
+
+ /// Opens an envelope with the agent's private key. Throws when it cannot.
+ public static string Open(byte[] envelope, string keyId, RSA privateKey, string context)
+ {
+ ArgumentNullException.ThrowIfNull(envelope);
+ ArgumentNullException.ThrowIfNull(privateKey);
+ ArgumentNullException.ThrowIfNull(context);
+ var sealedFor = KeyIdOf(envelope) ?? throw new CredentialException("Adgangskoden er ikke gemt i et format, agenten kender.");
+ if (!string.Equals(sealedFor, keyId, StringComparison.Ordinal))
+ {
+ throw new CredentialException("Adgangskoden er krypteret til en anden agent-nøgle (fx før agenten blev geninstalleret). Indtast den igen.");
+ }
+
+ try
+ {
+ var at = 2 + envelope[1];
+ var wrappedLength = BinaryPrimitives.ReadUInt16BigEndian(envelope.AsSpan(at));
+ at += 2;
+ var wrapped = envelope.AsSpan(at, wrappedLength).ToArray();
+ at += wrappedLength;
+ var nonce = envelope.AsSpan(at, NonceSize);
+ at += NonceSize;
+ var tag = envelope.AsSpan(at, TagSize);
+ at += TagSize;
+ var ciphertext = envelope.AsSpan(at);
+ var key = privateKey.Decrypt(wrapped, RSAEncryptionPadding.OaepSHA256);
+ try
+ {
+ var plaintext = new byte[ciphertext.Length];
+ using var aes = new AesGcm(key, TagSize);
+ aes.Decrypt(nonce, ciphertext, tag, plaintext, AssociatedData(keyId, context));
+ var secret = Encoding.UTF8.GetString(plaintext);
+ CryptographicOperations.ZeroMemory(plaintext);
+ return secret;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(key);
+ }
+ }
+ catch (Exception ex) when (ex is CryptographicException or ArgumentException)
+ {
+ throw new CredentialException("Adgangskoden kan ikke dekrypteres. Er server eller login ændret, uden at adgangskoden blev indtastet igen? Indtast den igen.", ex);
+ }
+ }
+
+ private static byte[] AssociatedData(string keyId, string context) => Encoding.UTF8.GetBytes(keyId + "\n" + context);
+}
+
+/// A stored password the agent cannot use. The message is Danish and shown to the user.
+public sealed class CredentialException : Exception
+{
+ public CredentialException()
+ {
+ }
+
+ public CredentialException(string message)
+ : base(message)
+ {
+ }
+
+ public CredentialException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/src/SQLInsight.Core/Configuration/DatabaseSelection.cs b/src/SQLInsight.Core/Configuration/DatabaseSelection.cs
new file mode 100644
index 0000000..2d4d157
--- /dev/null
+++ b/src/SQLInsight.Core/Configuration/DatabaseSelection.cs
@@ -0,0 +1,82 @@
+namespace SQLInsight.Core.Configuration;
+
+///
+/// Which databases on an instance are monitored, as chosen in SQLInsight Web (Opsætning → Overvågede servere). The
+/// system databases (master, model, msdb, tempdb) are always monitored: backups of master and msdb and the state of
+/// tempdb concern the whole instance. A database nobody has decided on yet (new since the last discovery) follows
+/// .
+///
+public sealed class DatabaseSelection
+{
+ private static readonly HashSet SystemDatabases = new(["master", "model", "msdb", "tempdb"], StringComparer.OrdinalIgnoreCase);
+
+ private readonly HashSet _monitored;
+ private readonly HashSet _notMonitored;
+ private readonly HashSet _dynamicsAxToHub;
+
+ /// Every database, and no Dynamics AX findings to the hub: what 1.2 and earlier did.
+ public static readonly DatabaseSelection All = new(true, [], [], []);
+
+ public DatabaseSelection(bool monitorNew, IEnumerable monitored, IEnumerable notMonitored, IEnumerable dynamicsAxToHub)
+ {
+ ArgumentNullException.ThrowIfNull(monitored);
+ ArgumentNullException.ThrowIfNull(notMonitored);
+ ArgumentNullException.ThrowIfNull(dynamicsAxToHub);
+ MonitorNew = monitorNew;
+ _monitored = new HashSet(monitored, StringComparer.OrdinalIgnoreCase);
+ _notMonitored = new HashSet(notMonitored, StringComparer.OrdinalIgnoreCase);
+ _notMonitored.ExceptWith(_monitored);
+ _dynamicsAxToHub = new HashSet(dynamicsAxToHub, StringComparer.OrdinalIgnoreCase);
+ }
+
+ /// Databases not yet decided on (new on the instance) are monitored.
+ public bool MonitorNew { get; }
+
+ /// Nothing is left out: every database is monitored.
+ public bool IsEverything => MonitorNew && _notMonitored.Count == 0;
+
+ public static bool IsSystemDatabase(string database) => SystemDatabases.Contains(database);
+
+ /// Whether the database-specific checks run in .
+ public bool Includes(string database)
+ {
+ ArgumentNullException.ThrowIfNull(database);
+ if (IsSystemDatabase(database) || _monitored.Contains(database))
+ {
+ return true;
+ }
+
+ return !_notMonitored.Contains(database) && MonitorNew;
+ }
+
+ ///
+ /// Whether findings about Dynamics AX in may be sent to SQLInsight Hub. Off unless the
+ /// customer turned it on for that database.
+ ///
+ public bool SendsDynamicsAxToHub(string? database) => database is not null && _dynamicsAxToHub.Contains(database) && Includes(database);
+
+ /// At least one database sends Dynamics AX findings to the hub.
+ public bool AnyDynamicsAxToHub => _dynamicsAxToHub.Any(Includes);
+
+ /// Keeps the databases in that are monitored.
+ public List Filter(IEnumerable names) => names.Where(Includes).ToList();
+}
+
+///
+/// Dynamics AX findings and SQLInsight Hub: they stay in the customer's network unless sending is turned on for the
+/// database (default off).
+///
+public static class DynamicsAxFindings
+{
+ public const string RulePrefix = "COM-AX-";
+
+ private const string DatabaseSubject = "database: ";
+
+ public static bool IsDynamicsAxRule(string ruleId) => ruleId?.StartsWith(RulePrefix, StringComparison.OrdinalIgnoreCase) == true;
+
+ /// The database a finding is about, from its subject (database: AX30), or null.
+ public static string? DatabaseOf(string? subject) =>
+ subject is not null && subject.StartsWith(DatabaseSubject, StringComparison.OrdinalIgnoreCase) && subject.Length > DatabaseSubject.Length
+ ? subject[DatabaseSubject.Length..]
+ : null;
+}
diff --git a/src/SQLInsight.Core/Configuration/GrantScript.cs b/src/SQLInsight.Core/Configuration/GrantScript.cs
new file mode 100644
index 0000000..57eb0a6
--- /dev/null
+++ b/src/SQLInsight.Core/Configuration/GrantScript.cs
@@ -0,0 +1,49 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace SQLInsight.Core.Configuration;
+
+///
+/// db/permissions/grant-least-privilege.sql, ready to run for one login: what SQLInsight Web shows for a newly added
+/// server. SQLInsight never runs it on the customer's server itself; a sysadmin runs it.
+///
+public static partial class GrantScript
+{
+ /// The script with filled in. For a Windows login it starts by creating the login if it is missing.
+ public static string For(string login, bool windowsLogin)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(login);
+ var literal = "N'" + login.Trim().Replace("'", "''", StringComparison.Ordinal) + "'";
+ var script = LoginPlaceholder().Replace(Template(), _ => "SET @LoginName = " + literal + ";", 1);
+ var header = new StringBuilder();
+ header.Append("/* Kør som sysadmin på instansen. Giver SQLInsight-agentens login ").Append(InComment(login.Trim())).Append(" de rettigheder, den skal bruge. */\r\n");
+ if (windowsLogin)
+ {
+ header.Append("IF SUSER_ID(").Append(literal).Append(") IS NULL\r\n CREATE LOGIN ")
+ .Append(Quote(login.Trim())).Append(" FROM WINDOWS;\r\nGO\r\n");
+ }
+ else
+ {
+ header.Append("/* SQL-login: opret det først, hvis det mangler, fx\r\n CREATE LOGIN ").Append(InComment(Quote(login.Trim())))
+ .Append(" WITH PASSWORD = N'…', CHECK_POLICY = ON; */\r\n");
+ }
+
+ return header.Append(script).ToString();
+ }
+
+ /// The script as shipped, with the placeholder login.
+ public static string Template()
+ {
+ using var stream = typeof(GrantScript).Assembly.GetManifestResourceStream("SQLInsight.Core.grant-least-privilege.sql")
+ ?? throw new InvalidOperationException("grant-least-privilege.sql mangler i programmet.");
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+
+ private static string InComment(string text) => text.Replace("*/", "* /", StringComparison.Ordinal).Replace("/*", "/ *", StringComparison.Ordinal);
+
+ private static string Quote(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
+
+ [GeneratedRegex(@"SET @LoginName = N'[^']*';[^\r\n]*")]
+ private static partial Regex LoginPlaceholder();
+}
diff --git a/src/SQLInsight.Core/Configuration/ServerAddress.cs b/src/SQLInsight.Core/Configuration/ServerAddress.cs
new file mode 100644
index 0000000..7ac9c35
--- /dev/null
+++ b/src/SQLInsight.Core/Configuration/ServerAddress.cs
@@ -0,0 +1,86 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace SQLInsight.Core.Configuration;
+
+/// How an instance entered in SQLInsight Web is addressed: server, server\instance, optionally a port.
+public static partial class ServerAddress
+{
+ public const int MaxServerLength = 256;
+ public const int MaxNameLength = 128;
+
+ /// The data source to connect to: server\instance, or server,port when a port is given (it then wins over the instance name).
+ public static string DataSource(string server, int? port)
+ {
+ ArgumentNullException.ThrowIfNull(server);
+ return port is { } p ? server.Trim() + "," + p.ToString(CultureInfo.InvariantCulture) : server.Trim();
+ }
+
+ /// Why and cannot be used (Danish), or null when they can.
+ public static string? Validate(string? server, int? port)
+ {
+ if (string.IsNullOrWhiteSpace(server))
+ {
+ return "Angiv serveren, fx sql01 eller sql01\\PROD.";
+ }
+
+ var trimmed = server.Trim();
+ if (trimmed.Length > MaxServerLength || !ServerPattern().IsMatch(trimmed))
+ {
+ return "Servernavnet må kun indeholde bogstaver, tal, punktum, bindestreg, understregning og én omvendt skråstreg før instansnavnet (fx sql01.firma.local\\PROD).";
+ }
+
+ if (port is not null and (< 1 or > 65535))
+ {
+ return "Porten skal være mellem 1 og 65535.";
+ }
+
+ return null;
+ }
+
+ /// Why cannot be the display name (Danish), or null.
+ public static string? ValidateName(string? name) =>
+ string.IsNullOrWhiteSpace(name) ? "Angiv et navn." :
+ name.Trim().Length > MaxNameLength ? $"Navnet må højst være {MaxNameLength} tegn." :
+ name.Any(char.IsControl) ? "Navnet må ikke indeholde kontroltegn." :
+ null;
+
+ [GeneratedRegex(@"^(tcp:)?[A-Za-z0-9._\-]+(\\[A-Za-z0-9_$#\-]+)?$")]
+ private static partial Regex ServerPattern();
+}
+
+///
+/// The Windows login a service account has on a SQL Server. Virtual and built-in machine accounts (NT SERVICE\…,
+/// LocalSystem, NETWORK SERVICE) only exist locally; on another server they arrive as the computer account
+/// DOMAIN\COMPUTER$. Domain accounts and gMSAs are the same everywhere.
+///
+public static partial class ServiceAccounts
+{
+ public static string LoginFor(string account, string sqlServer, string machineName, string domain)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(account);
+ var normalized = account.Trim();
+ if (normalized.Equals("LocalSystem", StringComparison.OrdinalIgnoreCase))
+ {
+ normalized = @"NT AUTHORITY\SYSTEM";
+ }
+
+ var machineIdentity = normalized.StartsWith(@"NT SERVICE\", StringComparison.OrdinalIgnoreCase)
+ || normalized.StartsWith(@"NT AUTHORITY\", StringComparison.OrdinalIgnoreCase);
+ return machineIdentity && !IsLocal(sqlServer, machineName) ? $@"{domain}\{machineName}$" : normalized;
+ }
+
+ /// Whether a SQL Server data source (server, server\instance, tcp:server,port …) is this computer.
+ public static bool IsLocal(string sqlServer, string machineName)
+ {
+ ArgumentNullException.ThrowIfNull(sqlServer);
+ var host = ProtocolPrefix().Replace(sqlServer.Trim(), string.Empty).Split('\\', ',')[0].Trim();
+ return host is "" or "." or "(local)" or "(localdb)" or "127.0.0.1" or "::1"
+ || host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
+ || host.Equals(machineName, StringComparison.OrdinalIgnoreCase)
+ || host.StartsWith(machineName + ".", StringComparison.OrdinalIgnoreCase);
+ }
+
+ [GeneratedRegex(@"^(tcp|np|lpc|admin):", RegexOptions.IgnoreCase)]
+ private static partial Regex ProtocolPrefix();
+}
diff --git a/src/SQLInsight.Core/SQLInsight.Core.csproj b/src/SQLInsight.Core/SQLInsight.Core.csproj
index b1a5cac..d26d127 100644
--- a/src/SQLInsight.Core/SQLInsight.Core.csproj
+++ b/src/SQLInsight.Core/SQLInsight.Core.csproj
@@ -2,4 +2,8 @@
SQLInsight.Core
+
+
+
+
diff --git a/tests/SQLInsight.Core.Tests/InstanceConfigurationTests.cs b/tests/SQLInsight.Core.Tests/InstanceConfigurationTests.cs
new file mode 100644
index 0000000..80d67ea
--- /dev/null
+++ b/tests/SQLInsight.Core.Tests/InstanceConfigurationTests.cs
@@ -0,0 +1,164 @@
+using System.Security.Cryptography;
+using SQLInsight.Core.Configuration;
+
+namespace SQLInsight.Core.Tests;
+
+public sealed class DatabaseSelectionTests
+{
+ [Fact]
+ public void All_monitors_every_database_and_sends_no_dynamics_ax_findings()
+ {
+ Assert.True(DatabaseSelection.All.IsEverything);
+ Assert.True(DatabaseSelection.All.Includes("Sales"));
+ Assert.False(DatabaseSelection.All.SendsDynamicsAxToHub("AX30"));
+ Assert.False(DatabaseSelection.All.AnyDynamicsAxToHub);
+ }
+
+ [Fact]
+ public void Ticked_databases_are_monitored_unticked_are_not_and_new_ones_follow_the_instance_setting()
+ {
+ var selection = new DatabaseSelection(monitorNew: false, ["AX30"], ["Archive"], []);
+
+ Assert.True(selection.Includes("ax30"));
+ Assert.False(selection.Includes("Archive"));
+ Assert.False(selection.Includes("NewDb"));
+ Assert.True(new DatabaseSelection(monitorNew: true, ["AX30"], ["Archive"], []).Includes("NewDb"));
+ Assert.Equal(["AX30"], selection.Filter(["AX30", "Archive", "NewDb"]));
+ }
+
+ [Theory]
+ [InlineData("master")]
+ [InlineData("MSDB")]
+ [InlineData("model")]
+ [InlineData("tempdb")]
+ public void System_databases_are_always_monitored(string database)
+ {
+ Assert.True(new DatabaseSelection(false, [], [database], []).Includes(database));
+ }
+
+ [Fact]
+ public void Dynamics_ax_findings_go_to_the_hub_only_for_opted_in_monitored_databases()
+ {
+ var selection = new DatabaseSelection(false, ["AX30", "AX40"], ["AXOLD"], ["AX30", "AXOLD"]);
+
+ Assert.True(selection.SendsDynamicsAxToHub("AX30"));
+ Assert.False(selection.SendsDynamicsAxToHub("AX40"));
+ Assert.False(selection.SendsDynamicsAxToHub("AXOLD")); // opted in, but not monitored
+ Assert.False(selection.SendsDynamicsAxToHub(null));
+ Assert.True(selection.AnyDynamicsAxToHub);
+ }
+
+ [Theory]
+ [InlineData("COM-AX-001", true)]
+ [InlineData("com-ax-012", true)]
+ [InlineData("COM-FT-001", false)]
+ [InlineData("SQLI-DB-001", false)]
+ public void Dynamics_ax_rules_are_recognised_by_their_id(string ruleId, bool expected) =>
+ Assert.Equal(expected, DynamicsAxFindings.IsDynamicsAxRule(ruleId));
+
+ [Theory]
+ [InlineData("database: AX30", "AX30")]
+ [InlineData("database: ", null)]
+ [InlineData("ag-database: g/r/AX30", null)]
+ [InlineData(null, null)]
+ public void Database_is_read_from_the_subject(string? subject, string? expected) =>
+ Assert.Equal(expected, DynamicsAxFindings.DatabaseOf(subject));
+}
+
+public sealed class CredentialEnvelopeTests
+{
+ private static (RSA Key, byte[] PublicKey) NewKey()
+ {
+ var rsa = RSA.Create(3072);
+ return (rsa, rsa.ExportSubjectPublicKeyInfo());
+ }
+
+ [Fact]
+ public void Sealed_password_opens_only_with_the_agent_key_and_the_same_server_and_login()
+ {
+ var (key, publicKey) = NewKey();
+ using var _ = key;
+ var context = CredentialEnvelope.Context("sql01\\PROD,1433", "sqlinsight");
+
+ var envelope = CredentialEnvelope.Seal("Hemmelig-1!", "agent-1", publicKey, context);
+
+ Assert.Equal("agent-1", CredentialEnvelope.KeyIdOf(envelope));
+ Assert.DoesNotContain("Hemmelig", System.Text.Encoding.UTF8.GetString(envelope), StringComparison.Ordinal);
+ Assert.Equal("Hemmelig-1!", CredentialEnvelope.Open(envelope, "agent-1", key, CredentialEnvelope.Context("SQL01\\prod,1433", "SQLINSIGHT")));
+ Assert.Throws(() => CredentialEnvelope.Open(envelope, "agent-1", key, CredentialEnvelope.Context("evil01", "sqlinsight")));
+ Assert.Throws(() => CredentialEnvelope.Open(envelope, "agent-1", key, CredentialEnvelope.Context("sql01\\PROD,1433", "sa")));
+ Assert.Throws(() => CredentialEnvelope.Open(envelope, "agent-2", key, context));
+ }
+
+ [Fact]
+ public void Another_agent_key_or_a_tampered_envelope_cannot_be_opened()
+ {
+ var (key, publicKey) = NewKey();
+ var (other, _) = NewKey();
+ using var a = key;
+ using var b = other;
+ var context = CredentialEnvelope.Context("sql01", "u");
+ var envelope = CredentialEnvelope.Seal("pw", "k", publicKey, context);
+
+ Assert.Throws(() => CredentialEnvelope.Open(envelope, "k", other, context));
+ envelope[^1] ^= 0xFF;
+ Assert.Throws(() => CredentialEnvelope.Open(envelope, "k", key, context));
+ Assert.Null(CredentialEnvelope.KeyIdOf([9, 9, 9]));
+ }
+}
+
+public sealed class ServerAddressTests
+{
+ [Theory]
+ [InlineData("sql01", null, "sql01")]
+ [InlineData(" sql01\\PROD ", null, "sql01\\PROD")]
+ [InlineData("sql01.firma.local\\PROD", 1450, "sql01.firma.local\\PROD,1450")]
+ public void Data_source_adds_the_port(string server, int? port, string expected) =>
+ Assert.Equal(expected, ServerAddress.DataSource(server, port));
+
+ [Theory]
+ [InlineData("sql01", null, true)]
+ [InlineData("sql01\\PROD", 1433, true)]
+ [InlineData("tcp:sql01.firma.local", null, true)]
+ [InlineData("", null, false)]
+ [InlineData("sql01;Integrated Security=false", null, false)]
+ [InlineData("sql01,1433", null, false)]
+ [InlineData("sql01\\a\\b", null, false)]
+ [InlineData("sql01", 0, false)]
+ [InlineData("sql01", 70000, false)]
+ public void Server_and_port_are_validated(string server, int? port, bool valid) =>
+ Assert.Equal(valid, ServerAddress.Validate(server, port) is null);
+
+ [Theory]
+ [InlineData(@"NT SERVICE\SQLInsight Agent", "sql01", "AGENT01", "FIRMA", @"FIRMA\AGENT01$")]
+ [InlineData(@"NT SERVICE\SQLInsight Agent", "agent01\\PROD", "AGENT01", "FIRMA", @"NT SERVICE\SQLInsight Agent")]
+ [InlineData(@"FIRMA\svc-sqlinsight$", "sql01", "AGENT01", "FIRMA", @"FIRMA\svc-sqlinsight$")]
+ [InlineData("LocalSystem", "sql01", "AGENT01", "FIRMA", @"FIRMA\AGENT01$")]
+ public void Service_account_login_on_the_server(string account, string server, string machine, string domain, string expected) =>
+ Assert.Equal(expected, ServiceAccounts.LoginFor(account, server, machine, domain));
+}
+
+public sealed class GrantScriptTests
+{
+ [Fact]
+ public void Script_is_filled_in_for_the_login_and_creates_a_missing_windows_login()
+ {
+ var script = GrantScript.For(@"FIRMA\AGENT01$", windowsLogin: true);
+
+ Assert.Contains(@"SET @LoginName = N'FIRMA\AGENT01$';", script, StringComparison.Ordinal);
+ Assert.DoesNotContain("SET @LoginName = N'DOMÆNE", script, StringComparison.Ordinal);
+ Assert.Contains(@"CREATE LOGIN [FIRMA\AGENT01$] FROM WINDOWS;", script, StringComparison.Ordinal);
+ Assert.Contains("VIEW SERVER STATE", script, StringComparison.Ordinal);
+ Assert.DoesNotContain("PXI_BATCHMONITORTABLE", script, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Quotes_in_a_login_are_escaped()
+ {
+ var script = GrantScript.For("o'brien]", windowsLogin: false);
+
+ Assert.Contains("SET @LoginName = N'o''brien]';", script, StringComparison.Ordinal);
+ Assert.Contains("CREATE LOGIN [o'brien]]]", script, StringComparison.Ordinal);
+ Assert.DoesNotContain("IF SUSER_ID(", script, StringComparison.Ordinal);
+ }
+}