From 6d755aa3d02c799c28166c3e0618b36e273c9ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Thu, 1 Oct 2026 08:18:26 +0200 Subject: [PATCH] Installer creates the repository database, logins and rights itself Entering a connection string in the installer is now enough: the new command `SQLInsight.Agent.exe setup-repository` runs as a deferred custom action after the services exist and sqlinsight.ini is written, and before they start. It runs as the installing user on purpose (SQL Server must see a person with rights, not SYSTEM) and: - creates the database from Database=... if it is missing - creates logins/users for the service accounts: agent db_owner, web db_datareader. NT SERVICE\... accounts arrive at another computer as DOMAIN\COMPUTER$, which then gets the login instead - runs the schema migrations - with MONITOR_SERVER: creates the agent's login there and applies grant-least-privilege.sql (embedded). A failure there is a warning only A repository failure stops the installation; the reason goes to the Application event log with a Danish hint (login, rights, certificate, unreachable). SKIP_REPOSITORY_SETUP=1 leaves it to a DBA. Safe to rerun. verify-msi.py allows this one action to impersonate and checks its placement, Return=check and condition. Tested against a local SQL Express (twice, idempotent) and with an unreachable server. Version 1.0.1. Co-Authored-By: Claude Opus 5.5 (1M context) --- Directory.Build.props | 2 +- docs/installation.md | 12 +- installer/SQLInsight.wxs | 17 + installer/verify-msi.py | 20 +- .../CustomRules/RuleCommands.cs | 2 +- src/SQLInsight.Agent/Program.cs | 7 +- src/SQLInsight.Agent/SQLInsight.Agent.csproj | 4 + src/SQLInsight.Agent/Setup/RepositorySetup.cs | 307 ++++++++++++++++++ .../RepositorySetupTests.cs | 49 +++ 9 files changed, 415 insertions(+), 5 deletions(-) create mode 100644 src/SQLInsight.Agent/Setup/RepositorySetup.cs create mode 100644 tests/SQLInsight.Agent.Tests/RepositorySetupTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index ebc3a09..270e9ef 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,7 @@ SQLInsight Pro Copyright (c) SEOSoft - 1.0.0 + 1.0.1 diff --git a/docs/installation.md b/docs/installation.md index fa27e83..4264f46 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -13,7 +13,7 @@ Begge bruger en **repository-database** på en SQL Server 2016+-instans, der hel ## Hurtigst: MSI-installeren -`SQLInsight-Pro--x64.msi` installerer agent og web som Windows-tjenester. .NET er indbygget, så serveren behøver ingen runtime. Hent den nyeste under [Releases](https://git.seosoft.dk/seo/sqlinsight-pro/releases), eller med `installer\get-latest-msi.ps1`. Opret først repository-databasen (trin 2) og giv rettigheder (trin 3). Kør derefter installeren som administrator: +`SQLInsight-Pro--x64.msi` installerer agent og web som Windows-tjenester. .NET er indbygget, så serveren behøver ingen runtime. Hent den nyeste under [Releases](https://git.seosoft.dk/seo/sqlinsight-pro/releases), eller med `installer\get-latest-msi.ps1`. Kør installeren som administrator, logget ind som en bruger, der er **sysadmin** på den SQL Server, repositoriet skal ligge på (eller har `dbcreator` og `securityadmin`). Installeren opretter selv repository-databasen, logins og rettigheder (trin 2 og 3), så de ikke skal laves i hånden: - **Med installationsguiden:** Første side spørger om forbindelsesstrengen til repositoriet, den første SQL Server, tjenestekonti og port. - **Stille, fx med SCCM/Intune:** @@ -34,11 +34,19 @@ msiexec /i SQLInsight-Pro-1.0.0-x64.msi /qn /l*v install.log ` | `WEB_ACCOUNT` | `NT SERVICE\SQLInsight Web` | Tjenestekonto til web. Skal have `db_datareader` på repositoriet | | `WEB_PORT` | `5080` | Port til webbrugerfladen. Installeren åbner den i Windows Firewall | | `ADDLOCAL` | `Agent,Web` | `Agent`, `Web` og `UpdateTask` (planlagt opgave, der installerer hentede opdateringer som SYSTEM, se [opdatering.md](opdatering.md)). `ALL` = alle tre | +| `SKIP_REPOSITORY_SETUP` | (ingen) | `1` = installeren opretter ikke database, logins og rettigheder. Brug det, når en DBA gør det (trin 2 og 3), eller når installationen kører som SYSTEM (SCCM/Intune) uden rettigheder på SQL Server | Installeren gør følgende: - Programmerne lægges i `C:\Program Files\SQLInsight\Agent` og `\Web`, og tjenesterne `SQLInsight Agent` og `SQLInsight Web` oprettes med automatisk start. - Værdierne fra installationen skrives i `sqlinsight.ini` ved siden af programmerne. Den læses efter `appsettings.json`, så begge filer kan redigeres bagefter. Genstart tjenesten efter en ændring. +- **Repositoriet sættes op med din Windows-konto** (ikke tjenesternes), før tjenesterne starter: + - databasen fra `Database=…` oprettes, hvis den mangler; + - tjenestekontiene får login og bruger: agenten `db_owner`, web `db_datareader`. Ligger SQL Server på en anden computer, kommer `NT SERVICE\…`-kontiene dertil som computerkontoen (`DOMÆNE\COMPUTER$`), og den får login'et i stedet; + - skemaet oprettes; + - på `MONITOR_SERVER` oprettes agentens login, og den får rettighederne fra `grant-least-privilege.sql`. Lykkes det ikke (fx ingen sysadmin dér), fortsætter installationen, og scriptet kan køres senere. + + Fejler opsætningen af repositoriet, stopper installationen og rulles tilbage. Årsagen står i hændelsesloggen (*Application*, kilden `SQLInsight Pro`). Opsætningen kan også køres igen bagefter, fx efter en ændret forbindelsesstreng: `"C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe" setup-repository --grant-monitored` (med `--agent-account`/`--web-account`, hvis der ikke bruges standardkonti). - `C:\ProgramData\SQLInsight` oprettes med adgang for kun SYSTEM, Administrators og agentens konto. Mappen indeholder lokale regler, regelpakker og ventende opdateringer. - Kilden `SQLInsight Pro` registreres i hændelsesloggen (se [integration.md](integration.md#scom-og-windows-hændelseslog)). @@ -59,6 +67,8 @@ Web kører her på Kestrel over http med Windows-login. Skal den bruge https ell ## 2. Repository-databasen +MSI-installeren gør dette selv (medmindre `SKIP_REPOSITORY_SETUP=1`). Ved manuel installation, eller når en DBA skal gøre det: + ```sql CREATE DATABASE SQLInsight; GO diff --git a/installer/SQLInsight.wxs b/installer/SQLInsight.wxs index 772257a..f423021 100644 --- a/installer/SQLInsight.wxs +++ b/installer/SQLInsight.wxs @@ -10,6 +10,8 @@ which reaches other servers as the computer account (DOMAIN\SERVER$). A gMSA: DOMAIN\svc-sqlinsight$ WEB_ACCOUNT Account for SQLInsight Web. Default NT SERVICE\SQLInsight Web WEB_PORT Port for the web UI (default 5080) + SKIP_REPOSITORY_SETUP 1 = do not create the repository database, logins and rights (the admin does it, see + docs/installation.md). Otherwise the installing user's Windows account does it. Features: Agent, Web (both by default) and UpdateTask under Agent (ADDLOCAL=ALL: installs downloaded updates hourly as SYSTEM). An upgrade keeps the features that were installed. --> @@ -34,6 +36,7 @@ + @@ -143,6 +146,17 @@ + + + + + 1 1 1 + 1 &UpdateTask=2 AND NOT UPGRADINGPRODUCTCODE &Web=2 AND NOT UPGRADINGPRODUCTCODE @@ -175,6 +190,8 @@ &UpdateTask=3 &Web=3 &Web=3 + + &Agent=3 AND REPOSITORY_CONNECTION AND NOT SKIP_REPOSITORY_SETUP diff --git a/installer/verify-msi.py b/installer/verify-msi.py index 6b9f792..a4f40a6 100755 --- a/installer/verify-msi.py +++ b/installer/verify-msi.py @@ -10,6 +10,9 @@ import tempfile DEFERRED = 0x400 IN_SCRIPT_NO_IMPERSONATE = 0x800 +CONTINUE_ON_ERROR = 0x40 +# Deferred actions that deliberately run as the installing user: SQL Server must see a person with rights there. +AS_INSTALLING_USER = {"SetupRepository"} def tables(msi): @@ -46,13 +49,28 @@ def main(msi): errors.append(f"{action}: er ikke i InstallExecuteSequence") elif kind & DEFERRED and not start < sequence[action] < end: errors.append(f"{action}: deferred, men ligger uden for InstallInitialize..InstallFinalize ({sequence[action]})") - elif kind & DEFERRED and not kind & IN_SCRIPT_NO_IMPERSONATE: + elif kind & DEFERRED and not kind & IN_SCRIPT_NO_IMPERSONATE and action not in AS_INSTALLING_USER: errors.append(f"{action}: deferred uden NoImpersonate (kører ikke som SYSTEM)") + elif action in AS_INSTALLING_USER and kind & IN_SCRIPT_NO_IMPERSONATE: + errors.append(f"{action}: skal køre som den installerende bruger, ikke SYSTEM") def after(action, standard): if sequence.get(action, 0) <= sequence[standard]: errors.append(f"{action} skal ligge efter {standard}") + # Repository setup: needs the services' accounts and the written sqlinsight.ini, and must finish before the + # services start; a failure must stop the installation, and an admin must be able to skip it. + after("SetupRepository", "InstallServices") + after("SetupRepository", "WriteIniValues") + if sequence.get("SetupRepository", 0) >= sequence["StartServices"]: + errors.append("SetupRepository skal ligge før StartServices") + if actions.get("SetupRepository", 0) & CONTINUE_ON_ERROR: + errors.append("SetupRepository: fejl skal stoppe installationen (Return=check)") + condition = next((r["Condition"] for r in rows("InstallExecuteSequence") if r["Action"] == "SetupRepository"), "") + if "SKIP_REPOSITORY_SETUP" not in condition or "REPOSITORY_CONNECTION" not in condition: + errors.append("SetupRepository: betingelsen skal kræve REPOSITORY_CONNECTION og respektere SKIP_REPOSITORY_SETUP") + after("SetAgentExePath", "CostFinalize") # AGENTDIR is only resolved there + after("SetDataFolderAcl", "InstallServices") # the virtual service account must exist after("CreateUpdateTask", "InstallFiles") after("OpenFirewall", "ReplaceFirewallRule") diff --git a/src/SQLInsight.Agent/CustomRules/RuleCommands.cs b/src/SQLInsight.Agent/CustomRules/RuleCommands.cs index d5e924a..2648426 100644 --- a/src/SQLInsight.Agent/CustomRules/RuleCommands.cs +++ b/src/SQLInsight.Agent/CustomRules/RuleCommands.cs @@ -99,7 +99,7 @@ public static class RuleCommands ? CustomRuleLoader.LoadDirectory(path, origin: null) : CustomRuleLoader.Parse(File.ReadAllText(path), Path.GetFileName(path), origin: null); - private static AgentOptions LoadAgentOptions() + internal static AgentOptions LoadAgentOptions() { var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"; var configuration = new ConfigurationBuilder() diff --git a/src/SQLInsight.Agent/Program.cs b/src/SQLInsight.Agent/Program.cs index d1296a5..937b0a9 100644 --- a/src/SQLInsight.Agent/Program.cs +++ b/src/SQLInsight.Agent/Program.cs @@ -8,12 +8,17 @@ using SQLInsight.Repository; using SQLInsight.Rules; using SQLInsight.Rules.Engine; -if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args) || UpdateCommands.Handles(args)) +if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args) || UpdateCommands.Handles(args) || SQLInsight.Agent.Setup.RepositorySetup.Handles(args)) { // Danish letters in a Windows console, which otherwise uses the OEM code page. Console.OutputEncoding = System.Text.Encoding.UTF8; } +if (SQLInsight.Agent.Setup.RepositorySetup.Handles(args)) +{ + return await SQLInsight.Agent.Setup.RepositorySetup.RunAsync(args, Console.Out); +} + if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args)) { return await SQLInsight.Agent.CustomRules.RuleCommands.RunAsync(args, Console.Out); diff --git a/src/SQLInsight.Agent/SQLInsight.Agent.csproj b/src/SQLInsight.Agent/SQLInsight.Agent.csproj index 82ea502..4ffc2fb 100644 --- a/src/SQLInsight.Agent/SQLInsight.Agent.csproj +++ b/src/SQLInsight.Agent/SQLInsight.Agent.csproj @@ -22,4 +22,8 @@ + + + + diff --git a/src/SQLInsight.Agent/Setup/RepositorySetup.cs b/src/SQLInsight.Agent/Setup/RepositorySetup.cs new file mode 100644 index 0000000..78e2c1d --- /dev/null +++ b/src/SQLInsight.Agent/Setup/RepositorySetup.cs @@ -0,0 +1,307 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using Microsoft.Data.SqlClient; +using SQLInsight.Connectivity; +using SQLInsight.Repository; + +namespace SQLInsight.Agent.Setup; + +/// +/// setup-repository [--agent-account <konto>] [--web-account <konto>] [--grant-monitored] +/// +/// Prepares everything the services need, with the rights of whoever runs it (the MSI runs it as the installing user, +/// right after the services are created): the repository database if it is missing, logins and users for the agent +/// (db_owner) and web (db_datareader) accounts, the schema, and with --grant-monitored the least-privilege rights +/// on the configured instances (db/permissions/grant-least-privilege.sql). Safe to run again. +/// +/// The connection string and instances are read from appsettings.json and sqlinsight.ini, like the service does. +/// +public static partial class RepositorySetup +{ + public const string Command = "setup-repository"; + private const string EventSource = "SQLInsight Pro"; + + public static bool Handles(string[] args) => args.FirstOrDefault() == Command; + + public static async Task RunAsync(string[] args, TextWriter output) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(output); + var log = new StringWriter(); + var both = new TeeWriter(output, log); + var agentAccount = Option(args, "--agent-account") ?? @"NT SERVICE\SQLInsight Agent"; + var webAccount = Option(args, "--web-account") ?? @"NT SERVICE\SQLInsight Web"; + var options = CustomRules.RuleCommands.LoadAgentOptions(); + + try + { + await PrepareRepositoryAsync(options.Repository.ConnectionString, agentAccount, webAccount, both).ConfigureAwait(false); + } + catch (Exception ex) when (ex is SqlException or InvalidOperationException or ArgumentException) + { + both.WriteLine($"FEJL: {ex.Message}"); + both.WriteLine(Hint(ex)); + Report(log.ToString(), Outcome.Error); + return 1; + } + + // The monitored instances are a convenience: an instance that cannot be reached now (or where the installing + // user is not sysadmin) is reported, but does not stop the installation; grant-least-privilege.sql can be run later. + var warnings = 0; + if (args.Contains("--grant-monitored")) + { + foreach (var instance in options.Instances.Where(i => !string.IsNullOrWhiteSpace(i.Server))) + { + try + { + await GrantMonitoredAsync(instance, agentAccount, both).ConfigureAwait(false); + } + catch (Exception ex) when (ex is SqlException or InvalidOperationException) + { + warnings++; + both.WriteLine($"ADVARSEL: rettigheder på {instance.Server} kunne ikke gives: {ex.Message}"); + both.WriteLine(" Kør db/permissions/grant-least-privilege.sql på instansen som sysadmin."); + } + } + } + + Report(log.ToString(), warnings > 0 ? Outcome.Warning : Outcome.Ok); + return 0; + } + + private static async Task PrepareRepositoryAsync(string? connectionString, string agentAccount, string webAccount, TextWriter output) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException("SQLInsight:Repository:ConnectionString er ikke sat (sqlinsight.ini eller appsettings.json)."); + } + + var repository = new SqlConnectionStringBuilder(connectionString) { Pooling = false }; + var database = repository.InitialCatalog; + if (string.IsNullOrWhiteSpace(database)) + { + throw new ArgumentException("Forbindelsesstrengen mangler Database=…, fx Database=SQLInsight."); + } + + var master = new SqlConnectionStringBuilder(repository.ConnectionString) { InitialCatalog = "master" }; + await using (var connection = new SqlConnection(master.ConnectionString)) + { + await connection.OpenAsync().ConfigureAwait(false); + output.WriteLine($"Forbundet til {repository.DataSource} som {await ScalarAsync(connection, "SELECT SUSER_SNAME()").ConfigureAwait(false)}."); + var created = await ScalarAsync(connection, """ + IF DB_ID(@db) IS NULL + BEGIN + DECLARE @sql nvarchar(max) = N'CREATE DATABASE ' + QUOTENAME(@db); + EXEC (@sql); + SELECT N'oprettet'; + END + ELSE SELECT N'findes'; + """, ("@db", database)).ConfigureAwait(false); + output.WriteLine($"Database {database}: {created}."); + + // A SQL login in the connection string is what the services use too; there are no Windows accounts to add. + if (repository.IntegratedSecurity) + { + foreach (var (login, _) in RepositoryLogins(agentAccount, webAccount, repository.DataSource)) + { + output.WriteLine($"Login {login}: {await EnsureLoginAsync(connection, login).ConfigureAwait(false)}."); + } + } + } + + if (repository.IntegratedSecurity) + { + await using var connection = new SqlConnection(repository.ConnectionString); + await connection.OpenAsync().ConfigureAwait(false); + foreach (var (login, role) in RepositoryLogins(agentAccount, webAccount, repository.DataSource)) + { + var user = await ScalarAsync(connection, """ + DECLARE @user sysname = (SELECT name FROM sys.database_principals WHERE sid = SUSER_SID(@login)); + IF @user IS NULL + BEGIN + SET @user = @login; + DECLARE @create nvarchar(max) = N'CREATE USER ' + QUOTENAME(@user) + N' FOR LOGIN ' + QUOTENAME(@login); + EXEC (@create); + END + IF @user <> N'dbo' AND IS_ROLEMEMBER(@role, @user) <> 1 + BEGIN + DECLARE @add nvarchar(max) = N'ALTER ROLE ' + QUOTENAME(@role) + N' ADD MEMBER ' + QUOTENAME(@user); + EXEC (@add); + END + SELECT @user; + """, ("@login", login), ("@role", role)).ConfigureAwait(false); + output.WriteLine($"Bruger {user} i {database}: {role}."); + } + } + + var applied = await SchemaMigrator.MigrateAsync(repository.ConnectionString).ConfigureAwait(false); + output.WriteLine(applied > 0 ? $"Skema: {applied} migreringer kørt." : "Skema: opdateret."); + } + + private static async Task GrantMonitoredAsync(MonitoredInstance instance, string agentAccount, TextWriter output) + { + if (instance.Authentication != AuthenticationMode.Windows) + { + output.WriteLine($"{instance.Server}: springes over ({instance.Authentication}); giv rettigheder med grant-least-privilege.sql."); + return; + } + + var login = LoginFor(agentAccount, instance.Server, Environment.MachineName, Environment.UserDomainName); + var target = new SqlConnectionStringBuilder + { + DataSource = instance.Server, + InitialCatalog = "master", + IntegratedSecurity = true, + Encrypt = instance.Encryption == EncryptionMode.Mandatory ? SqlConnectionEncryptOption.Mandatory : SqlConnectionEncryptOption.Optional, + TrustServerCertificate = instance.TrustServerCertificate, + ApplicationName = "SQLInsight Pro setup", + Pooling = false, + }; + await using var connection = new SqlConnection(target.ConnectionString); + connection.InfoMessage += (_, e) => output.WriteLine($" {e.Message}"); + await connection.OpenAsync().ConfigureAwait(false); + output.WriteLine($"Login {login} på {instance.Server}: {await EnsureLoginAsync(connection, login).ConfigureAwait(false)}."); + + await using var command = new SqlCommand(GrantScript(), connection) { CommandTimeout = 300 }; + command.Parameters.AddWithValue("@login", login); + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + output.WriteLine($"Rettigheder givet på {instance.Server}."); + } + + /// + /// The logins the services connect as, with their role in the repository. Both services on another computer than + /// SQL Server arrive as the computer account, which then gets db_owner (it covers reading). + /// + public static IReadOnlyList<(string Login, string Role)> RepositoryLogins(string agentAccount, string webAccount, string sqlServer) + { + var agent = LoginFor(agentAccount, sqlServer, Environment.MachineName, Environment.UserDomainName); + var web = LoginFor(webAccount, sqlServer, Environment.MachineName, Environment.UserDomainName); + return string.Equals(agent, web, StringComparison.OrdinalIgnoreCase) + ? [(agent, "db_owner")] + : [(agent, "db_owner"), (web, "db_datareader")]; + } + + /// + /// The Windows login a service account has on . 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 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); + } + + private static async Task EnsureLoginAsync(SqlConnection connection, string login) => + await ScalarAsync(connection, """ + IF SUSER_ID(@login) IS NULL + BEGIN + DECLARE @sql nvarchar(max) = N'CREATE LOGIN ' + QUOTENAME(@login) + N' FROM WINDOWS'; + EXEC (@sql); + SELECT N'oprettet'; + END + ELSE SELECT N'findes'; + """, ("@login", login)).ConfigureAwait(false); + + private static async Task ScalarAsync(SqlConnection connection, string sql, params (string Name, string Value)[] parameters) + { + await using var command = new SqlCommand(sql, connection) { CommandTimeout = 300 }; + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + + return Convert.ToString(await command.ExecuteScalarAsync().ConfigureAwait(false), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + } + + /// db/permissions/grant-least-privilege.sql with the login as the parameter @login instead of the placeholder. + public static string GrantScript() + { + using var stream = typeof(RepositorySetup).Assembly.GetManifestResourceStream("grant-least-privilege.sql") + ?? throw new InvalidOperationException("grant-least-privilege.sql mangler i programmet."); + using var reader = new StreamReader(stream); + var script = LoginPlaceholder().Replace(reader.ReadToEnd(), "SET @LoginName = @login;", 1); + return script.Contains("SET @LoginName = @login;", StringComparison.Ordinal) + ? script + : throw new InvalidOperationException("grant-least-privilege.sql: linjen SET @LoginName = N'…' blev ikke fundet."); + } + + private static string Hint(Exception ex) => ex switch + { + SqlException { Number: 18456 } => "Din Windows-konto kan ikke logge ind på SQL Server. Installér som en bruger, der er sysadmin på instansen.", + SqlException { Number: 262 or 1802 or 15247 } => "Din konto mangler rettighed til at oprette databaser eller logins (dbcreator og securityadmin, eller sysadmin).", + SqlException { Number: 15401 } => "Windows-kontoen findes ikke på SQL Server-maskinen. Ligger SQL Server på en anden computer, skal computeren være i samme domæne.", + SqlException { Number: -2146893019 } or SqlException { InnerException: System.Security.Authentication.AuthenticationException } => + "SQL Server bruger et certifikat, der ikke er tillid til. Tilføj TrustServerCertificate=true i forbindelsesstrengen.", + SqlException { Number: 2 or 53 or -1 } => "SQL Server kunne ikke nås. Tjek servernavnet, at instansen kører, og firewallen (TCP 1433).", + _ => "Installér med SKIP_REPOSITORY_SETUP=1 og opret databasen selv (docs/installation.md, trin 2), hvis det ikke kan løses.", + }; + + private enum Outcome { Ok, Warning, Error } + + private static void Report(string text, Outcome outcome) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + try + { + var type = outcome switch { Outcome.Error => EventLogEntryType.Error, Outcome.Warning => EventLogEntryType.Warning, _ => EventLogEntryType.Information }; + EventLog.WriteEntry(EventSource, "Opsætning af repository (setup-repository):" + Environment.NewLine + text, type); + } + catch (Exception ex) when (ex is InvalidOperationException or System.Security.SecurityException or System.ComponentModel.Win32Exception) + { + // The event source is registered by the installer; outside it the console output is enough. + } + } + + private static string? Option(string[] args, string name) + { + var i = Array.IndexOf(args, name); + return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; + } + + [GeneratedRegex(@"^(tcp|np|lpc|admin):", RegexOptions.IgnoreCase)] + private static partial Regex ProtocolPrefix(); + + [GeneratedRegex(@"SET @LoginName = N'[^']*';[^\r\n]*")] + private static partial Regex LoginPlaceholder(); + + private sealed class TeeWriter(TextWriter first, TextWriter second) : TextWriter + { + public override System.Text.Encoding Encoding => first.Encoding; + + public override void Write(char value) + { + first.Write(value); + second.Write(value); + } + + public override void WriteLine(string? value) + { + first.WriteLine(value); + second.WriteLine(value); + } + } +} diff --git a/tests/SQLInsight.Agent.Tests/RepositorySetupTests.cs b/tests/SQLInsight.Agent.Tests/RepositorySetupTests.cs new file mode 100644 index 0000000..6a4e346 --- /dev/null +++ b/tests/SQLInsight.Agent.Tests/RepositorySetupTests.cs @@ -0,0 +1,49 @@ +using SQLInsight.Agent.Setup; + +namespace SQLInsight.Agent.Tests; + +public sealed class RepositorySetupTests +{ + [Theory] + [InlineData(".")] + [InlineData(@".\SQLEXPRESS")] + [InlineData("(local)")] + [InlineData("localhost,1433")] + [InlineData(@"tcp:localhost\SQLEXPRESS")] + [InlineData("SQLMON01")] + [InlineData(@"sqlmon01\SQLEXPRESS")] + [InlineData("sqlmon01.kunde.local,1433")] + public void Recognises_this_computer(string server) => Assert.True(RepositorySetup.IsLocal(server, "SQLMON01")); + + [Theory] + [InlineData("sql02")] + [InlineData(@"sql02\PROD")] + [InlineData("sqlmon011")] + [InlineData("tcp:10.0.0.5,1433")] + public void Recognises_other_computers(string server) => Assert.False(RepositorySetup.IsLocal(server, "SQLMON01")); + + [Fact] + public void Virtual_account_keeps_its_name_on_the_same_computer() => + Assert.Equal(@"NT SERVICE\SQLInsight Agent", RepositorySetup.LoginFor(@"NT SERVICE\SQLInsight Agent", @".\SQLEXPRESS", "SQLMON01", "KUNDE")); + + [Theory] + [InlineData(@"NT SERVICE\SQLInsight Agent")] + [InlineData(@"NT AUTHORITY\NETWORK SERVICE")] + [InlineData("LocalSystem")] + public void Machine_accounts_arrive_as_the_computer_account_on_another_server(string account) => + Assert.Equal(@"KUNDE\SQLMON01$", RepositorySetup.LoginFor(account, "sql02", "SQLMON01", "KUNDE")); + + [Fact] + public void Domain_accounts_and_gmsas_are_the_same_everywhere() => + Assert.Equal(@"KUNDE\svc-sqlinsight$", RepositorySetup.LoginFor(@"KUNDE\svc-sqlinsight$", "sql02", "SQLMON01", "KUNDE")); + + [Fact] + public void Grant_script_takes_the_login_as_a_parameter() + { + var script = RepositorySetup.GrantScript(); + + Assert.Contains("SET @LoginName = @login;", script, StringComparison.Ordinal); + Assert.DoesNotContain("SET @LoginName = N'", script, StringComparison.Ordinal); + Assert.Contains("VIEW ANY DEFINITION", script, StringComparison.Ordinal); + } +}