sqlinsight-pro/src/SQLInsight.Agent/Setup/RepositorySetup.cs
Søren Eggert Lundsteen Olsen 29e1909e98 Review 1.3: one implementation of a service account's SQL login
RepositorySetup.LoginFor/IsLocal duplicated Core's ServiceAccounts, which the web
uses for the GRANT script. Setup now uses ServiceAccounts too, so the login the
installer grants and the one the web shows cannot drift apart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:22:41 +02:00

275 lines
14 KiB
C#

using System.Diagnostics;
using System.Text.RegularExpressions;
using Microsoft.Data.SqlClient;
using SQLInsight.Connectivity;
using SQLInsight.Core.Configuration;
using SQLInsight.Repository;
namespace SQLInsight.Agent.Setup;
/// <summary>
/// <c>setup-repository [--agent-account &lt;konto&gt;] [--web-account &lt;konto&gt;] [--grant-monitored]</c>
/// <para>
/// 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 <c>--grant-monitored</c> the least-privilege rights
/// on the configured instances (db/permissions/grant-least-privilege.sql). Safe to run again.
/// </para>
/// The connection string and instances are read from appsettings.json and sqlinsight.ini, like the service does.
/// </summary>
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<int> 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 = ServiceAccounts.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}.");
}
/// <summary>
/// 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).
/// </summary>
public static IReadOnlyList<(string Login, string Role)> RepositoryLogins(string agentAccount, string webAccount, string sqlServer)
{
var agent = ServiceAccounts.LoginFor(agentAccount, sqlServer, Environment.MachineName, Environment.UserDomainName);
var web = ServiceAccounts.LoginFor(webAccount, sqlServer, Environment.MachineName, Environment.UserDomainName);
return string.Equals(agent, web, StringComparison.OrdinalIgnoreCase)
? [(agent, "db_owner")]
: [(agent, "db_owner"), (web, "db_datareader")];
}
private static async Task<string> 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<string> 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;
}
/// <summary>db/permissions/grant-least-privilege.sql with the login as the parameter @login instead of the placeholder.</summary>
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(@"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);
}
}
}