using System.Security.Cryptography; using System.Text.RegularExpressions; using Dapper; using SwBLL.ServerConfig; using GB5Shared.DTO.Framework.Login; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SwBLL.ClientDatabase; using SwDAL.DTO.ClientDatabase; using SwDAL.DTO.DbServer; using SwDAL.Enums; using VaultSharp; namespace SwBLL.Provisioning; /// /// SQL Server implementation of IClientDatabaseProvisioner. Postgres is deliberately out of /// scope for this release (DbType.PostgreSQL throws NotSupportedException) — matches this /// module's own "harden before extending" sequencing rather than expanding scope on two axes /// (multi-dialect DB creation + per-database login isolation) in the same pass. /// /// Every operation here runs against SQL Server's admin ("master") connection or the freshly /// created target database directly via Microsoft.Data.SqlClient/Dapper — deliberately NOT /// through ITargetDbExecutor's ExecuteScriptAsync/ExecuteQueryAsync (those are scoped to /// already-existing target application databases and enforce a SELECT-only mode that RESTORE/ /// CREATE/ALTER DATABASE statements don't fit), except for the contained-user CREATE USER /// script, which genuinely is just DDL against the (now-existing) target database. /// public class ClientDatabaseProvisioner : IClientDatabaseProvisioner { private static readonly Regex ValidIdentifier = new(@"^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.Compiled); private static readonly Regex ValidBackupFileName = new(@"^[A-Za-z0-9_\-\.]+\.bak$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly ITargetDbExecutor _targetDbExecutor; private readonly IProvisioningBLL _provisioningBLL; private readonly IClientDatabaseBLL _clientDatabaseBLL; private readonly IServerConfigBLL _serverConfigBLL; private readonly IVaultClient _vaultClient; private readonly SqlServerProvisioningOptions _options; private readonly ILogger _logger; public ClientDatabaseProvisioner( ITargetDbExecutor targetDbExecutor, IProvisioningBLL provisioningBLL, IClientDatabaseBLL clientDatabaseBLL, IServerConfigBLL serverConfigBLL, IVaultClient vaultClient, IOptions options, ILogger logger) { _targetDbExecutor = targetDbExecutor; _provisioningBLL = provisioningBLL; _clientDatabaseBLL = clientDatabaseBLL; _serverConfigBLL = serverConfigBLL; _vaultClient = vaultClient; _options = options.Value; _logger = logger; } public async Task CreateFromSnapshotAsync( ClientDatabaseDTO clientDb, DbServerDTO server, string templateBackupRef, LoginDTO login, CancellationToken ct) { EnsureSqlServer(server); var databaseName = EnsureValidIdentifier(clientDb.DatabaseName, nameof(clientDb.DatabaseName)); var clientDbCode = EnsureValidIdentifier(clientDb.ClientDbCode, nameof(clientDb.ClientDbCode)); if (!ValidBackupFileName.IsMatch(templateBackupRef)) throw new ArgumentException( "TemplateBackupRef must be a plain .bak filename (no path segments).", nameof(templateBackupRef)); _logger.LogInformation( "Creating client database {DatabaseName} from snapshot {TemplateBackupRef} on server {DbServerId}", databaseName, templateBackupRef, server.DbServerId); var adminConnStr = _targetDbExecutor.BuildAdminConnectionString(server); await EnsureDatabaseDoesNotExistAsync(adminConnStr, databaseName, ct).ConfigureAwait(false); var backupPath = CombinePath(_options.BackupSourcePath, templateBackupRef); var fileList = await QueryBackupFileListAsync(adminConnStr, backupPath, ct).ConfigureAwait(false); var dataFile = fileList.FirstOrDefault(f => f.Type.Equals("D", StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException($"Backup '{templateBackupRef}' has no data file entry."); var logFile = fileList.FirstOrDefault(f => f.Type.Equals("L", StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException($"Backup '{templateBackupRef}' has no log file entry."); var dataDest = CombinePath(_options.DataFileDestinationPath, $"{databaseName}.mdf"); var logDest = CombinePath(_options.LogFileDestinationPath, $"{databaseName}_log.ldf"); var restoreSql = $@" RESTORE DATABASE [{databaseName}] FROM DISK = N'{EscapeLiteral(backupPath)}' WITH MOVE N'{EscapeLiteral(dataFile.LogicalName)}' TO N'{EscapeLiteral(dataDest)}', MOVE N'{EscapeLiteral(logFile.LogicalName)}' TO N'{EscapeLiteral(logDest)}', REPLACE, STATS = 10;"; await ExecuteAdminStatementAsync(adminConnStr, restoreSql, ct).ConfigureAwait(false); await AssertDatabaseDefaultsAsync( adminConnStr, clientDb, databaseName, dataFile.LogicalName, logFile.LogicalName, ct) .ConfigureAwait(false); var appCredential = await CreateContainedUsersAndStoreCredentialsAsync( clientDb, server, databaseName, clientDbCode, login, ct) .ConfigureAwait(false); await RegisterAndLinkServerConfigAsync(clientDb, server, databaseName, appCredential, login, ct) .ConfigureAwait(false); } public async Task CreateFromScriptsAsync( ClientDatabaseDTO clientDb, DbServerDTO server, int baselineUpgradePackageId, LoginDTO login, CancellationToken ct) { EnsureSqlServer(server); var databaseName = EnsureValidIdentifier(clientDb.DatabaseName, nameof(clientDb.DatabaseName)); var clientDbCode = EnsureValidIdentifier(clientDb.ClientDbCode, nameof(clientDb.ClientDbCode)); _logger.LogInformation( "Creating empty client database {DatabaseName} for baseline UpgradePackage {PackageId} on server {DbServerId}", databaseName, baselineUpgradePackageId, server.DbServerId); var adminConnStr = _targetDbExecutor.BuildAdminConnectionString(server); await EnsureDatabaseDoesNotExistAsync(adminConnStr, databaseName, ct).ConfigureAwait(false); await ExecuteAdminStatementAsync(adminConnStr, $"CREATE DATABASE [{databaseName}];", ct).ConfigureAwait(false); // Plain CREATE DATABASE with no explicit ON PRIMARY/LOG ON clause always names the // primary data file after the database itself and the log file "{name}_log" — SQL // Server's own default, not something this code chooses. var dataLogicalName = databaseName; var logLogicalName = $"{databaseName}_log"; await AssertDatabaseDefaultsAsync( adminConnStr, clientDb, databaseName, dataLogicalName, logLogicalName, ct) .ConfigureAwait(false); var appCredential = await CreateContainedUsersAndStoreCredentialsAsync( clientDb, server, databaseName, clientDbCode, login, ct) .ConfigureAwait(false); await RegisterAndLinkServerConfigAsync(clientDb, server, databaseName, appCredential, login, ct) .ConfigureAwait(false); // Reuse the existing, unmodified DDL-deployment flow for the baseline schema — no new // script-execution logic needed here at all. await _provisioningBLL.ProvisionClientDatabase(clientDb.ClientDbId, baselineUpgradePackageId, login, ct) .ConfigureAwait(false); } // ── DB-level defaults (recovery model, PAGE_VERIFY, autogrowth, containment) ──────────── private async Task AssertDatabaseDefaultsAsync( string adminConnStr, ClientDatabaseDTO clientDb, string databaseName, string dataLogicalName, string logLogicalName, CancellationToken ct) { // FULL recovery for the main OLTP database (point-in-time recovery matters for real // transactional client data); SIMPLE for Report/Archive — less critical, avoids // unnecessary log growth/backup overhead on databases that are themselves derived. var recoveryModel = (DatabaseRole)clientDb.DatabaseRole == DatabaseRole.Main ? "FULL" : "SIMPLE"; var sql = $@" ALTER DATABASE [{databaseName}] SET RECOVERY {recoveryModel}; ALTER DATABASE [{databaseName}] SET PAGE_VERIFY CHECKSUM; ALTER DATABASE [{databaseName}] SET AUTO_SHRINK OFF; ALTER DATABASE [{databaseName}] SET ALLOW_SNAPSHOT_ISOLATION ON; ALTER DATABASE [{databaseName}] SET CONTAINMENT = PARTIAL; ALTER DATABASE [{databaseName}] MODIFY FILE (NAME = N'{EscapeLiteral(dataLogicalName)}', FILEGROWTH = {_options.DataFileAutogrowthMB}MB); ALTER DATABASE [{databaseName}] MODIFY FILE (NAME = N'{EscapeLiteral(logLogicalName)}', FILEGROWTH = {_options.LogFileAutogrowthMB}MB);"; // READ_COMMITTED_SNAPSHOT ON is deliberately NOT asserted here — it changes transaction // semantics platform-wide and is left as an explicit decision for whoever operates this // server, not something creation silently defaults. await ExecuteAdminStatementAsync(adminConnStr, sql, ct).ConfigureAwait(false); } // ── Registering the new database with the legacy MSERVER/MSERVERCONFIG registry ──────── /// /// Makes a freshly created database visible to the rest of GB5 at runtime: registers it in /// MSERVER/MSERVERCONFIG (the table ReportConnectionResolver/ServerConfigCache actually read), /// storing the {clientdb}_app contained-user credential as the routable connection, then for /// non-Main roles links the new ServerConfigId back onto the sibling Main row's /// ReportServerConfigId/ArchiveServerConfigId so ReportConnectionResolver picks it up with no /// changes to that resolver itself (§11.5/§11.6 of the Thread 6 plan). /// private async Task RegisterAndLinkServerConfigAsync( ClientDatabaseDTO clientDb, DbServerDTO server, string databaseName, (string Username, string Password) appCredential, LoginDTO login, CancellationToken ct) { var role = (DatabaseRole)clientDb.DatabaseRole; // New convention, additive to TCMS's existing 1=TEST value — see MServerConfigWriteDTO's // doc comment. 0=Production/OLTP, 2=Report, 3=Archive. byte dbInstanceType = role switch { DatabaseRole.Main => (byte)0, DatabaseRole.Report => (byte)2, DatabaseRole.Archive => (byte)3, _ => throw new NotSupportedException($"Unhandled DatabaseRole {role}.") }; var request = new RegisterServerConfigRequest { ConnectionName = $"{clientDb.ClientDbCode}-{role}".ToUpperInvariant(), ServerMachineName = server.HostName ?? throw new InvalidOperationException( $"DbServerId {server.DbServerId} has no HostName — cannot register MSERVER."), DatabaseType = server.DbType, DatabaseName = databaseName, DatabaseUserName = appCredential.Username, DatabasePassword = appCredential.Password, DatabasePort = server.Port.ToString(), DbInstanceType = dbInstanceType, ClientId = clientDb.TenantId }; var serverConfigId = await _serverConfigBLL.RegisterDatabaseAsync(request, login, ct) .ConfigureAwait(false); await _clientDatabaseBLL.UpdateServerConfigIdAsync(clientDb.ClientDbId, serverConfigId, login, ct) .ConfigureAwait(false); if (role != DatabaseRole.Main) { var mainServerConfigId = await _clientDatabaseBLL .GetMainServerConfigIdForModelAsync(clientDb.DbModelId, login, ct) .ConfigureAwait(false); if (mainServerConfigId is null) { _logger.LogWarning( "Client database {ClientDbId} (role {Role}) registered as ServerConfigId {ServerConfigId}, " + "but no Main-role sibling has a ServerConfigId yet for DbModelId {DbModelId} — " + "ReportConnectionResolver will not route to it until the Main database is registered too.", clientDb.ClientDbId, role, serverConfigId, clientDb.DbModelId); return; } await _serverConfigBLL.LinkReportOrArchiveAsync( mainServerConfigId.Value, serverConfigId, isArchive: role == DatabaseRole.Archive, ct) .ConfigureAwait(false); } } // ── Per-database contained users (dba / app / readonly) ───────────────────────────────── private async Task<(string Username, string Password)> CreateContainedUsersAndStoreCredentialsAsync( ClientDatabaseDTO clientDb, DbServerDTO server, string databaseName, string clientDbCode, LoginDTO login, CancellationToken ct) { // Contained database users (CONTAINMENT = PARTIAL, asserted in AssertDatabaseDefaultsAsync) // authenticate entirely inside this one database — there is no server-level login for a // credential here to even attempt using against a different client's database. This is // the structural fix for "a user with one DB's password should not be able to access // other DBs": it's not a permission to remember to deny, it's architecturally impossible. var dbaLogin = $"{clientDbCode}_dba"; var appLogin = $"{clientDbCode}_app"; var readOnlyLogin = $"{clientDbCode}_readonly"; var dbaPassword = GeneratePassword(); var appPassword = GeneratePassword(); var readOnlyPassword = GeneratePassword(); var createUsersSql = $@" CREATE USER [{dbaLogin}] WITH PASSWORD = N'{EscapeLiteral(dbaPassword)}'; ALTER ROLE db_owner ADD MEMBER [{dbaLogin}]; CREATE USER [{appLogin}] WITH PASSWORD = N'{EscapeLiteral(appPassword)}'; ALTER ROLE db_datareader ADD MEMBER [{appLogin}]; ALTER ROLE db_datawriter ADD MEMBER [{appLogin}]; CREATE USER [{readOnlyLogin}] WITH PASSWORD = N'{EscapeLiteral(readOnlyPassword)}'; ALTER ROLE db_datareader ADD MEMBER [{readOnlyLogin}];"; // Note: db_datareader alone never restricts a login's ability to create *local* #temp // tables in tempdb — that's a tempdb-level default granted to every login, not something // requiring a grant here. Nothing above touches it. var targetConnStr = _targetDbExecutor.BuildConnectionString(server, databaseName); await _targetDbExecutor.ExecuteScriptAsync(targetConnStr, createUsersSql, ct).ConfigureAwait(false); var dbaVaultPath = $"sqlworkbench/clientdb/{clientDb.ClientDbId}/dba-password"; var appVaultPath = $"sqlworkbench/clientdb/{clientDb.ClientDbId}/app-password"; var readOnlyVaultPath = $"sqlworkbench/clientdb/{clientDb.ClientDbId}/readonly-password"; await WriteSecretAsync(dbaVaultPath, dbaPassword, ct).ConfigureAwait(false); await WriteSecretAsync(appVaultPath, appPassword, ct).ConfigureAwait(false); await WriteSecretAsync(readOnlyVaultPath, readOnlyPassword, ct).ConfigureAwait(false); await _clientDatabaseBLL.UpdateCredentialVaultPathsAsync( clientDb.ClientDbId, dbaVaultPath, appVaultPath, readOnlyVaultPath, login, ct) .ConfigureAwait(false); _logger.LogInformation( "Created contained users {DbaLogin}/{AppLogin}/{ReadOnlyLogin} for client database {ClientDbId}", dbaLogin, appLogin, readOnlyLogin, clientDb.ClientDbId); return (appLogin, appPassword); } // ── Helpers ────────────────────────────────────────────────────────────────────────────── /// Guards against RESTORE ... WITH REPLACE silently overwriting an existing database, /// or CREATE DATABASE racing/colliding with one — both would otherwise proceed against a name /// collision with no warning. A collision here means an upstream naming/uniqueness problem /// (e.g. a stale MSWCLIENTDATABASE row, or two clients assigned the same DatabaseName) that /// must be resolved explicitly, never silently overwritten. private async Task EnsureDatabaseDoesNotExistAsync(string adminConnStr, string databaseName, CancellationToken ct) { if (await _targetDbExecutor.DatabaseExistsAsync(adminConnStr, databaseName, ct).ConfigureAwait(false)) throw new InvalidOperationException( $"A database named '{databaseName}' already exists on this server — refusing to proceed. " + "Resolve the naming collision (choose a different DatabaseName, or explicitly handle the " + "existing database out-of-band) before retrying."); } private static void EnsureSqlServer(DbServerDTO server) { if (server.DbType != (byte)SwDAL.Enums.DbType.SqlServer) throw new NotSupportedException( $"Client database creation only supports SQL Server in this release (DbServerId {server.DbServerId} is DbType {server.DbType})."); } private static string EnsureValidIdentifier(string value, string paramName) { if (string.IsNullOrWhiteSpace(value) || !ValidIdentifier.IsMatch(value)) throw new ArgumentException( $"'{value}' is not a valid identifier (letters/digits/underscore only, must not start with a digit).", paramName); return value; } private static string EscapeLiteral(string value) => value.Replace("'", "''"); private static string CombinePath(string directory, string fileName) => $"{directory.TrimEnd('/', '\\')}\\{fileName}"; private static string GeneratePassword() { // 24 random bytes -> Base64 gives upper/lower/digit/symbol variety, comfortably clearing // SQL Server's default CHECK_POLICY complexity requirement (3 of 4 categories). var bytes = RandomNumberGenerator.GetBytes(24); return Convert.ToBase64String(bytes); } private async Task WriteSecretAsync(string vaultPath, string value, CancellationToken ct) => await _vaultClient.V1.Secrets.KeyValue.V2.WriteSecretAsync( path: vaultPath, data: new Dictionary { ["value"] = value }, mountPoint: "secret") .ConfigureAwait(false); private async Task> QueryBackupFileListAsync( string adminConnStr, string backupPath, CancellationToken ct) { await using var conn = new SqlConnection(adminConnStr); await conn.OpenAsync(ct).ConfigureAwait(false); var sql = $"RESTORE FILELISTONLY FROM DISK = N'{EscapeLiteral(backupPath)}';"; var rows = await conn.QueryAsync( new CommandDefinition(sql, cancellationToken: ct, commandTimeout: _options.ProvisioningCommandTimeoutSeconds)) .ConfigureAwait(false); return rows.ToList(); } private async Task ExecuteAdminStatementAsync(string adminConnStr, string sql, CancellationToken ct) { await using var conn = new SqlConnection(adminConnStr); await conn.OpenAsync(ct).ConfigureAwait(false); await conn.ExecuteAsync( new CommandDefinition(sql, cancellationToken: ct, commandTimeout: _options.ProvisioningCommandTimeoutSeconds)) .ConfigureAwait(false); } /// Subset of RESTORE FILELISTONLY's real result columns — Dapper maps by name and /// ignores the many other columns that statement returns. private sealed class BackupFileListEntry { public string LogicalName { get; set; } = string.Empty; public string PhysicalName { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; // "D" = data, "L" = log } }