From d45bee960a0b1f9616ff27dfd7adab1cc2a38715 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 18 Sep 2026 11:10:28 +0700 Subject: [PATCH] fix: harden terminal recovery and deployment --- .env.example | 1 + Dockerfile | 2 +- apps/KronaStaffTerminal/App.xaml.cs | 34 ++- apps/KronaStaffTerminal/MainPage.xaml.cs | 215 ++++++++++++------ .../Models/TerminalModels.cs | 13 ++ .../Services/FingerprintScanner.cs | 68 ++++-- .../Services/StartupService.cs | 11 +- .../Services/TerminalStore.cs | 149 ++++++++++-- compose.yaml | 41 +++- docs/DEPLOYMENT.md | 6 +- .../ADR-001-technology-and-runtime-modes.md | 4 +- migrations/0003_app_role_privileges.down.sql | 4 + migrations/0003_app_role_privileges.up.sql | 17 ++ scripts/migrate.mjs | 52 ++++- scripts/migration-checksum.d.mts | 6 + scripts/migration-checksum.mjs | 22 ++ src/config.ts | 18 +- tests/unit/config.test.ts | 10 + tests/unit/deployment.test.ts | 27 +++ tests/unit/migration-checksum.test.ts | 27 +++ 20 files changed, 585 insertions(+), 142 deletions(-) create mode 100644 migrations/0003_app_role_privileges.down.sql create mode 100644 migrations/0003_app_role_privileges.up.sql create mode 100644 scripts/migration-checksum.d.mts create mode 100644 scripts/migration-checksum.mjs create mode 100644 tests/unit/deployment.test.ts create mode 100644 tests/unit/migration-checksum.test.ts diff --git a/.env.example b/.env.example index a0f046c..2637c39 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ PORT=8080 LOG_LEVEL=info STAFF_RUNTIME_MODE=standalone POSTGRES_MIGRATION_PASSWORD=local-migration-only +POSTGRES_MIGRATION_USER=krona_migrator POSTGRES_APP_PASSWORD=local-app-only DATABASE_MIGRATION_URL=postgres://krona_migrator:local-migration-only@postgres:5432/krona_staff DATABASE_URL=postgres://krona_staff_app:local-app-only@postgres:5432/krona_staff diff --git a/Dockerfile b/Dockerfile index a7e521a..6e08fe5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,5 +18,5 @@ RUN npm ci --omit=dev && npm cache clean --force COPY --from=build /app/dist ./dist USER app EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -q -O - http://127.0.0.1:8080/live || exit 1 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -q -O - http://127.0.0.1:8080/api/v1/staff/live || exit 1 CMD ["node", "dist/server.js"] diff --git a/apps/KronaStaffTerminal/App.xaml.cs b/apps/KronaStaffTerminal/App.xaml.cs index 55a4616..7a7d45f 100644 --- a/apps/KronaStaffTerminal/App.xaml.cs +++ b/apps/KronaStaffTerminal/App.xaml.cs @@ -16,13 +16,14 @@ namespace KronaStaffTerminal; /// public partial class App : Application { - private const string InstanceMutexName = @"Local\KronaStaffTerminal.Singleton"; - private const string ActivationEventName = @"Local\KronaStaffTerminal.Activate"; + private const string InstanceMutexName = @"Global\KronaStaffTerminal.Singleton"; + private const string ActivationEventName = @"Global\KronaStaffTerminal.Activate"; private TrayService? _trayService; private Microsoft.UI.Dispatching.DispatcherQueueTimer? _autostartTimer; private Mutex? _instanceMutex; private EventWaitHandle? _activationEvent; private CancellationTokenSource? _activationCancellation; + private Task? _activationTask; /// /// The main application window. Use App.Window from any class that needs /// the window reference (for dialogs, pickers, interop, etc.). @@ -88,7 +89,7 @@ public partial class App : Application _trayService = new TrayService(ShowWindow, RequestExit); _activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName); _activationCancellation = new CancellationTokenSource(); - _ = Task.Run(() => ListenForActivation(_activationCancellation.Token)); + _activationTask = Task.Run(() => ListenForActivation(_activationCancellation.Token)); Window.Activate(); if (Environment.GetCommandLineArgs().Any(argument => argument.Equals("--autostart", StringComparison.OrdinalIgnoreCase))) @@ -114,6 +115,7 @@ public partial class App : Application { if (_activationEvent?.WaitOne(TimeSpan.FromSeconds(1)) == true) { + if (cancellationToken.IsCancellationRequested) return; DispatcherQueue.TryEnqueue(ShowWindow); } } @@ -141,10 +143,28 @@ public partial class App : Application public void CompleteExit() { _activationCancellation?.Cancel(); - _activationCancellation?.Dispose(); - _activationCancellation = null; - _activationEvent?.Dispose(); - _activationEvent = null; + _activationEvent?.Set(); + var activationStopped = true; + try + { + activationStopped = _activationTask?.Wait(TimeSpan.FromSeconds(2)) != false; + } + catch (AggregateException exception) + { + CrashLogService.Write(exception.Flatten()); + } + if (activationStopped) + { + _activationTask = null; + _activationCancellation?.Dispose(); + _activationCancellation = null; + _activationEvent?.Dispose(); + _activationEvent = null; + } + else + { + CrashLogService.Write(new TimeoutException("Слушатель активации не завершился; его ресурсы оставлены до выхода процесса.")); + } _instanceMutex?.ReleaseMutex(); _instanceMutex?.Dispose(); _instanceMutex = null; diff --git a/apps/KronaStaffTerminal/MainPage.xaml.cs b/apps/KronaStaffTerminal/MainPage.xaml.cs index f2488f6..e5830c2 100644 --- a/apps/KronaStaffTerminal/MainPage.xaml.cs +++ b/apps/KronaStaffTerminal/MainPage.xaml.cs @@ -23,9 +23,9 @@ public sealed partial class MainPage : Page private bool _adminNavigationInProgress; private bool _stateLoadFailed; private bool _scannerReconnectPending; - private int _failedPinAttempts; - private DateTimeOffset _pinBlockedUntilUtc; private string? _registrationEmployeeId; + private bool _refreshingLists; + private string _lastAdminActor = "unknown"; private DateTimeOffset _adminSessionExpiresAtUtc; private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5); @@ -73,6 +73,10 @@ public sealed partial class MainPage : Page RefreshLists(); ConnectScanner(); MainTabs.SelectedItem = TerminalTab; + if (_store.RecoveryNotice is not null) + { + SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning); + } } private void Page_Unloaded(object sender, RoutedEventArgs e) @@ -209,7 +213,8 @@ public sealed partial class MainPage : Page _state.Settings.AdminPinSalt = pin.Salt; _state.Settings.AdminPinHash = pin.Hash; _state.Settings.AdminPinIterations = pin.Iterations; - await _store.SaveAsync(_state); + await _store.SaveAsync(_state, createRecoveryPoint: true); + _lastAdminActor = "initial-pin"; return true; } @@ -284,7 +289,7 @@ public sealed partial class MainPage : Page IsAdministrator = AdministratorCheckBox.IsChecked == true }; _state.Employees.Add(employee); - await _store.SaveAsync(_state); + await _store.SaveAsync(_state, createRecoveryPoint: true); EmployeeNameBox.Text = ""; PersonnelNumberBox.Text = ""; PositionBox.Text = ""; @@ -374,12 +379,7 @@ public sealed partial class MainPage : Page 2 => "Женский", _ => "" }; - foreach (var attendance in _state.Attendance.Where(item => item.EmployeeId == employee.Id)) - { - attendance.EmployeeName = name; - } - - await _store.SaveAsync(_state); + await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); EmployeesList.SelectedItem = employee; SetStatus($"Данные сотрудника «{name}» обновлены. Отпечатки и журнал сохранены.", InfoBarSeverity.Success); @@ -420,6 +420,7 @@ public sealed partial class MainPage : Page private void EmployeesList_SelectionChanged(object sender, SelectionChangedEventArgs e) { + if (_refreshingLists) return; if (_registrationEmployeeId is null) return; if (EmployeesList.SelectedItem is EmployeeRecord employee && employee.Id == _registrationEmployeeId) return; CancelRegistration(); @@ -443,9 +444,9 @@ public sealed partial class MainPage : Page Label = $"Отпечаток {employee.Fingerprints.Count + 1}" }); _registrationEmployeeId = null; - await _store.SaveAsync(_state); + await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); - TerminalMessage.Text = $"Новый отпечаток сотрудника «{employee.DisplayName}» зарегистрирован"; + TerminalMessage.Text = $"Новый отпечаток сотрудника «{TerminalDisplayName(employee)}» зарегистрирован"; SetStatus($"Добавлен отпечаток №{employee.Fingerprints.Count} сотрудника «{employee.DisplayName}».", InfoBarSeverity.Success); } @@ -482,7 +483,7 @@ public sealed partial class MainPage : Page if (!await AuthorizeAdminAsync("удаление отпечатков сотрудника")) return; employee.Fingerprints.Clear(); - await _store.SaveAsync(_state); + await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); ConnectScanner(); SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success); @@ -503,7 +504,7 @@ public sealed partial class MainPage : Page return; } employee.IsAdministrator = !employee.IsAdministrator; - await _store.SaveAsync(_state); + await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); SetStatus(employee.IsAdministrator ? $"{employee.DisplayName} назначен администратором." : $"Права администратора у {employee.DisplayName} сняты.", InfoBarSeverity.Success); } @@ -539,7 +540,7 @@ public sealed partial class MainPage : Page } _state.Employees.Remove(employee); - await _store.SaveAsync(_state); + await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); ConnectScanner(); SetStatus("Сотрудник и его отпечаток удалены.", InfoBarSeverity.Success); @@ -554,7 +555,8 @@ public sealed partial class MainPage : Page { if (employee.IsAdministrator) { - TerminalMessage.Text = $"Администратор {employee.DisplayName} подтверждён"; + _lastAdminActor = $"employee:{employee.Id}"; + TerminalMessage.Text = $"Администратор {TerminalDisplayName(employee)} подтверждён"; _adminAuthCompletion.TrySetResult(true); _adminAuthDialog?.Hide(); } @@ -578,7 +580,7 @@ public sealed partial class MainPage : Page if (last is not null && now - last.OccurredAtUtc < guard) { var remaining = Math.Max(1, (int)Math.Ceiling((guard - (now - last.OccurredAtUtc)).TotalSeconds)); - TerminalMessage.Text = $"{employee.DisplayName}\nПовторный скан отклонён\nПовторите через {remaining} сек."; + TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {remaining} сек."; SetStatus($"Повторный скан защищён: событие не создано. Последнее событие — «{last.EventType}».", InfoBarSeverity.Informational); return; } @@ -602,7 +604,7 @@ public sealed partial class MainPage : Page } await _store.SaveAsync(_state); RefreshLists(); - TerminalMessage.Text = $"{employee.DisplayName}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}"; + TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}"; SetStatus($"{eventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success); var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee.DisplayName, eventType, now.ToLocalTime()); @@ -632,7 +634,7 @@ public sealed partial class MainPage : Page if (!await AuthorizeAdminAsync("очистка журнала прихода и ухода")) return; _state.Attendance.Clear(); - await _store.SaveAsync(_state); + await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); TerminalMessage.Text = "Журнал отметок очищен"; SetStatus("Журнал прихода и ухода очищен.", InfoBarSeverity.Success); @@ -641,6 +643,19 @@ public sealed partial class MainPage : Page private async void ExportAttendance_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("экспорт журнала")) return; + var warning = new ContentDialog + { + XamlRoot = XamlRoot, + Title = "Экспорт персональных данных", + Content = "CSV-файл не шифруется и содержит ФИО, табельные номера, должности и время отметок. Сохраняйте его только в защищённом месте.", + PrimaryButtonText = "Продолжить экспорт", + CloseButtonText = "Отмена", + DefaultButton = ContentDialogButton.Close + }; + if (await warning.ShowAsync() != ContentDialogResult.Primary) return; + _lastAdminActor = "unknown"; + if (!await AuthorizeAdminAsync("экспорт персональных данных", requireIdentifiedActor: true)) return; + var picker = new FileSavePicker { SuggestedFileName = $"krona-staff-attendance-{DateTime.Now:yyyy-MM-dd}" @@ -650,25 +665,44 @@ public sealed partial class MainPage : Page var file = await picker.PickSaveFileAsync(); if (file is null) return; - var csv = new StringBuilder("Сотрудник;Табельный номер;Должность;Событие;Время;Доставка\r\n"); - foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc)) + AppendSecurityAudit("attendance.export", "started", $"records={_state.Attendance.Count}; file={file.Name}"); + await _store.SaveAsync(_state); + try { - var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId); - csv.Append(Csv(item.EmployeeName)).Append(';') - .Append(Csv(employee?.PersonnelNumber ?? "")).Append(';') - .Append(Csv(employee?.Position ?? "")).Append(';') - .Append(Csv(item.EventType)).Append(';') - .Append(Csv(item.LocalTime)).Append(';') - .Append(Csv(item.DeliveryStatus)).Append("\r\n"); + var csv = new StringBuilder("Сотрудник;Табельный номер;Должность;Событие;Время;Доставка\r\n"); + foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc)) + { + var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId); + csv.Append(Csv(item.EmployeeName)).Append(';') + .Append(Csv(employee?.PersonnelNumber ?? "")).Append(';') + .Append(Csv(employee?.Position ?? "")).Append(';') + .Append(Csv(item.EventType)).Append(';') + .Append(Csv(item.LocalTime)).Append(';') + .Append(Csv(item.DeliveryStatus)).Append("\r\n"); + } + await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8); + AppendSecurityAudit("attendance.export", "succeeded", $"records={_state.Attendance.Count}; file={file.Name}"); + await _store.SaveAsync(_state); + SetStatus($"Журнал экспортирован: {file.Name}", InfoBarSeverity.Success); + } + catch (Exception exception) + { + AppendSecurityAudit("attendance.export", "failed", $"records={_state.Attendance.Count}; error={exception.GetType().Name}"); + await _store.SaveAsync(_state); + CrashLogService.Write(exception); + SetStatus($"Экспорт не выполнен: {exception.Message}", InfoBarSeverity.Error); } - await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8); - SetStatus($"Журнал экспортирован: {file.Name}", InfoBarSeverity.Success); } private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\""; public async Task AuthorizeExitAsync() { + if (_stateLoadFailed) + { + SetStatus("Хранилище не открыто. Программа завершает работу без изменения данных.", InfoBarSeverity.Warning); + return true; + } if (!SecurityConfigured) { SetStatus("Выход заблокирован: сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning); @@ -686,26 +720,37 @@ public sealed partial class MainPage : Page private bool SecurityConfigured => PinConfigured || AdministratorFingerprintConfigured; - private async Task AuthorizeAdminAsync(string operation) + private async Task AuthorizeAdminAsync(string operation, bool requireIdentifiedActor = false) { - var remainingLockout = _pinBlockedUntilUtc - DateTimeOffset.UtcNow; - if (remainingLockout > TimeSpan.Zero) + var remainingLockout = _state.Settings.AdminPinBlockedUntilUtc - DateTimeOffset.UtcNow; + var pinBlocked = remainingLockout > TimeSpan.Zero; + var hasAdministratorFingerprint = AdministratorFingerprintConfigured; + if (requireIdentifiedActor && !hasAdministratorFingerprint) + { + SetStatus("Для экспорта персональных данных требуется отпечаток сотрудника-администратора.", InfoBarSeverity.Warning); + return false; + } + if (pinBlocked && !hasAdministratorFingerprint) { SetStatus($"Слишком много неверных попыток PIN. Повторите через {Math.Ceiling(remainingLockout.TotalSeconds)} сек.", InfoBarSeverity.Warning); return false; } - var hasAdministratorFingerprint = AdministratorFingerprintConfigured; var pinBox = new PasswordBox { Header = "PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek, - MinWidth = 280 + MinWidth = 280, + IsEnabled = !pinBlocked && !requireIdentifiedActor }; var content = new StackPanel { Spacing = 12, MinWidth = 320 }; content.Children.Add(new TextBlock { - Text = hasAdministratorFingerprint - ? "Введите PIN или приложите зарегистрированный палец администратора." + Text = requireIdentifiedActor + ? "Для записи автора экспорта приложите зарегистрированный палец сотрудника-администратора." + : hasAdministratorFingerprint + ? pinBlocked + ? $"PIN временно заблокирован ещё на {Math.Ceiling(remainingLockout.TotalSeconds)} сек. Приложите зарегистрированный палец администратора." + : "Введите PIN или приложите зарегистрированный палец администратора." : "Введите PIN. После входа зарегистрируйте отпечаток администратора.", TextWrapping = TextWrapping.Wrap }); @@ -718,6 +763,7 @@ public sealed partial class MainPage : Page Title = $"Вход администратора: {operation}", Content = content, PrimaryButtonText = "Войти по PIN", + IsPrimaryButtonEnabled = !pinBlocked && !requireIdentifiedActor, CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Primary }; @@ -726,7 +772,8 @@ public sealed partial class MainPage : Page _adminAuthCompletion.Task.Result; _adminAuthCompletion.TrySetResult(false); var pinAuthorized = false; - if (dialogResult == ContentDialogResult.Primary) + string verifiedPin = pinBox.Password; + if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor) { pinAuthorized = await Task.Run(() => AdminPinService.Verify( @@ -742,17 +789,36 @@ public sealed partial class MainPage : Page _state.Settings.AdminPinIterations))); } var authorized = fingerprintAuthorized || pinAuthorized; + var securityStateChanged = false; if (authorized) { - _failedPinAttempts = 0; - _pinBlockedUntilUtc = default; + _state.Settings.FailedAdminPinAttempts = 0; + _state.Settings.AdminPinBlockedUntilUtc = default; + securityStateChanged = true; + if (pinAuthorized) + { + _lastAdminActor = "local-pin"; + if (_state.Settings.AdminPinIterations < AdminPinService.CurrentIterations) + { + if (!AdminPinService.Verify(verifiedPin, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations)) + { + verifiedPin = verifiedPin.Trim(); + } + var upgraded = await Task.Run(() => AdminPinService.Hash(verifiedPin)); + _state.Settings.AdminPinSalt = upgraded.Salt; + _state.Settings.AdminPinHash = upgraded.Hash; + _state.Settings.AdminPinIterations = upgraded.Iterations; + } + } } - else if (dialogResult == ContentDialogResult.Primary) + else if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor) { - _failedPinAttempts++; - var delaySeconds = Math.Min(30, 1 << Math.Min(_failedPinAttempts - 1, 5)); - _pinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); + _state.Settings.FailedAdminPinAttempts++; + var delaySeconds = Math.Min(900, 1 << Math.Min(_state.Settings.FailedAdminPinAttempts - 1, 10)); + _state.Settings.AdminPinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); + securityStateChanged = true; } + if (securityStateChanged) await _store.SaveAsync(_state); _adminAuthCompletion = null; _adminAuthDialog = null; SetStatus(authorized @@ -777,7 +843,7 @@ public sealed partial class MainPage : Page _state.Settings.AdminPinHash = pin.Hash; _state.Settings.AdminPinIterations = pin.Iterations; NewAdminPinBox.Password = ""; - await _store.SaveAsync(_state); + await _store.SaveAsync(_state, createRecoveryPoint: true); SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success); } @@ -785,7 +851,7 @@ public sealed partial class MainPage : Page { if (!await EnsureAdminSessionAsync("изменение настроек")) return; CopySettingsFromControls(); - await _store.SaveAsync(_state); + await _store.SaveAsync(_state, createRecoveryPoint: true); ApplyStartupSetting(); ConnectScanner(); SetStatus("Настройки сохранены.", InfoBarSeverity.Success); @@ -843,26 +909,47 @@ public sealed partial class MainPage : Page private void RefreshLists() { - EmployeesList.ItemsSource = null; - EmployeesList.ItemsSource = _state.Employees.OrderBy(employee => employee.DisplayName).ToList(); - AttendanceList.ItemsSource = null; - AttendanceList.ItemsSource = _state.Attendance - .OrderByDescending(item => item.OccurredAtUtc) - .Take(100) - .Select(item => _state.Settings.ShowEmployeeNamesOnTerminal ? item : new AttendanceRecord - { - Id = item.Id, - EmployeeId = item.EmployeeId, - EmployeeName = MaskName(item.EmployeeName), - OccurredAtUtc = item.OccurredAtUtc, - ReaderIndex = item.ReaderIndex, - EventType = item.EventType, - NotificationDelivered = item.NotificationDelivered, - NotificationError = item.NotificationError - }) - .ToList(); + var selectedEmployeeId = (EmployeesList.SelectedItem as EmployeeRecord)?.Id; + _refreshingLists = true; + try + { + var employees = _state.Employees.OrderBy(employee => employee.DisplayName).ToList(); + EmployeesList.ItemsSource = employees; + EmployeesList.SelectedItem = employees.FirstOrDefault(employee => employee.Id == selectedEmployeeId); + AttendanceList.ItemsSource = _state.Attendance + .OrderByDescending(item => item.OccurredAtUtc) + .Take(100) + .Select(item => _state.Settings.ShowEmployeeNamesOnTerminal ? item : new AttendanceRecord + { + Id = item.Id, + EmployeeId = item.EmployeeId, + EmployeeName = MaskName(item.EmployeeName), + OccurredAtUtc = item.OccurredAtUtc, + ReaderIndex = item.ReaderIndex, + EventType = item.EventType, + NotificationDelivered = item.NotificationDelivered, + NotificationError = item.NotificationError + }) + .ToList(); + } + finally + { + _refreshingLists = false; + } } + private string TerminalDisplayName(EmployeeRecord employee) => + _state.Settings.ShowEmployeeNamesOnTerminal ? employee.DisplayName : MaskName(employee.DisplayName); + + private void AppendSecurityAudit(string action, string outcome, string detail) => + _state.SecurityAudit.Add(new TerminalAuditRecord + { + Action = action, + Actor = _lastAdminActor, + Outcome = outcome, + Detail = detail + }); + private static string MaskName(string name) => string.Join(' ', name .Split(' ', StringSplitOptions.RemoveEmptyEntries) .Select(part => part.Length == 1 ? $"{part[0]}…" : $"{part[0]}***")); diff --git a/apps/KronaStaffTerminal/Models/TerminalModels.cs b/apps/KronaStaffTerminal/Models/TerminalModels.cs index c61a193..62eb45f 100644 --- a/apps/KronaStaffTerminal/Models/TerminalModels.cs +++ b/apps/KronaStaffTerminal/Models/TerminalModels.cs @@ -67,6 +67,8 @@ public sealed class TerminalSettings public string AdminPinSalt { get; set; } = ""; public string AdminPinHash { get; set; } = ""; public int AdminPinIterations { get; set; } = 120_000; + public int FailedAdminPinAttempts { get; set; } + public DateTimeOffset AdminPinBlockedUntilUtc { get; set; } public int DuplicateScanGuardSeconds { get; set; } = 120; public int MaximumShiftHours { get; set; } = 20; public bool ShowEmployeeNamesOnTerminal { get; set; } @@ -82,5 +84,16 @@ public sealed class TerminalState { public List Employees { get; set; } = []; public List Attendance { get; set; } = []; + public List SecurityAudit { get; set; } = []; public TerminalSettings Settings { get; set; } = new(); } + +public sealed class TerminalAuditRecord +{ + public string Id { get; set; } = UlidGenerator.New(); + public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow; + public string Action { get; set; } = ""; + public string Actor { get; set; } = ""; + public string Outcome { get; set; } = ""; + public string Detail { get; set; } = ""; +} diff --git a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs index a0d14da..c14b1c6 100644 --- a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs +++ b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs @@ -16,6 +16,7 @@ public sealed class FingerprintScanner : IDisposable private DateTimeOffset _registrationExpiresAtUtc; private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue; private bool _fingerOnSensor; + private int _cleanupScheduled; public event Action? FingerRecognized; public event Action? RegistrationProgress; @@ -271,26 +272,63 @@ public sealed class FingerprintScanner : IDisposable _captureCancellation?.Cancel(); try { - _captureTask?.Wait(TimeSpan.FromSeconds(2)); + if (_captureTask is not null && !_captureTask.Wait(TimeSpan.FromSeconds(2))) + { + if (Interlocked.Exchange(ref _cleanupScheduled, 1) == 0) + { + _ = _captureTask.ContinueWith( + _ => + { + ReleaseNativeResources(); + ConnectionLost?.Invoke(); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + throw new TimeoutException( + "Поток чтения сканера не завершился вовремя. Ресурсы будут освобождены сразу после безопасной остановки чтения."); + } } catch (AggregateException exception) { CrashLogService.Write(exception.Flatten()); } - _captureCancellation = null; - _captureTask = null; - if (_database != IntPtr.Zero) - { - zkfp2.DBFree(_database); - _database = IntPtr.Zero; - } - if (_device != IntPtr.Zero) - { - zkfp2.CloseDevice(_device); - _device = IntPtr.Zero; - } - zkfp2.Terminate(); + ReleaseNativeResources(); } - public void Dispose() => Disconnect(); + private void ReleaseNativeResources() + { + lock (_gate) + { + _captureCancellation?.Dispose(); + _captureCancellation = null; + _captureTask = null; + _fingerOnSensor = false; + if (_database != IntPtr.Zero) + { + zkfp2.DBFree(_database); + _database = IntPtr.Zero; + } + if (_device != IntPtr.Zero) + { + zkfp2.CloseDevice(_device); + _device = IntPtr.Zero; + } + zkfp2.Terminate(); + Interlocked.Exchange(ref _cleanupScheduled, 0); + } + } + + public void Dispose() + { + try + { + Disconnect(); + } + catch (TimeoutException exception) + { + CrashLogService.Write(exception); + } + } } diff --git a/apps/KronaStaffTerminal/Services/StartupService.cs b/apps/KronaStaffTerminal/Services/StartupService.cs index 330d780..c7f9534 100644 --- a/apps/KronaStaffTerminal/Services/StartupService.cs +++ b/apps/KronaStaffTerminal/Services/StartupService.cs @@ -24,17 +24,8 @@ public static class StartupService private static string ResolveExecutablePath() { - var executablePath = Environment.ProcessPath + return Environment.ProcessPath ?? throw new InvalidOperationException("Не удалось определить путь программы."); - var debugSegment = $"{Path.DirectorySeparatorChar}Debug{Path.DirectorySeparatorChar}"; - if (!executablePath.Contains(debugSegment, StringComparison.OrdinalIgnoreCase)) return executablePath; - - var releasePath = executablePath.Replace( - debugSegment, - $"{Path.DirectorySeparatorChar}Release{Path.DirectorySeparatorChar}", - StringComparison.OrdinalIgnoreCase); - if (File.Exists(releasePath)) return releasePath; - throw new InvalidOperationException("Автозагрузка доступна после сборки Release-версии программы."); } public static bool IsEnabled() diff --git a/apps/KronaStaffTerminal/Services/TerminalStore.cs b/apps/KronaStaffTerminal/Services/TerminalStore.cs index 2ae81f4..e18f158 100644 --- a/apps/KronaStaffTerminal/Services/TerminalStore.cs +++ b/apps/KronaStaffTerminal/Services/TerminalStore.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text.Json; using KronaStaffTerminal.Models; +using Microsoft.Win32; namespace KronaStaffTerminal.Services; @@ -8,6 +9,9 @@ public sealed class TerminalStore { private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray(); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private const string RegistryPath = @"Software\Krona\StaffTerminal"; + private const string InitializedValueName = "StateInitialized"; + private static readonly TimeSpan AutomaticRecoveryPointInterval = TimeSpan.FromHours(12); private readonly SemaphoreSlim _saveGate = new(1, 1); private readonly string _filePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @@ -15,10 +19,30 @@ public sealed class TerminalStore "StaffTerminal", "terminal-state.dat"); + public string? RecoveryNotice { get; private set; } + public async Task LoadAsync() { + RecoveryNotice = null; var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState(); - if (sourcePath is null) return new TerminalState(); + if (sourcePath is null) + { + var recovered = await TryRecoverFromBackupAsync(); + if (recovered is not null) + { + RecoveryNotice = "Основной файл отсутствовал. Данные восстановлены из защищённой резервной копии."; + await SaveAsync(recovered); + return recovered; + } + + if (WasPreviouslyInitialized() || HasRecoveryArtifacts()) + { + throw new InvalidDataException( + "Основной файл состояния отсутствует, но терминал уже был настроен. Первичная настройка заблокирована до восстановления данных."); + } + + return new TerminalState(); + } try { @@ -28,9 +52,15 @@ public sealed class TerminalStore { await SaveAsync(state); } + else + { + MarkInitialized(); + } return state; } - catch (Exception primaryException) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) + catch (Exception primaryException) when ( + string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase) && + IsCorruptStateException(primaryException)) { var corruptPath = _filePath + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}"; try @@ -44,34 +74,65 @@ public sealed class TerminalStore new AggregateException(primaryException, moveException)); } - foreach (var backupPath in BackupPaths()) + var recovered = await TryRecoverFromBackupAsync(); + if (recovered is not null) { - if (!File.Exists(backupPath)) continue; - try - { - var recovered = await ReadStateAsync(backupPath); - MigrateFingerprints(recovered); - await SaveAsync(recovered); - return recovered; - } - catch (Exception backupException) - { - CrashLogService.Write(backupException); - } + RecoveryNotice = $"Повреждённый файл сохранён как {Path.GetFileName(corruptPath)}. Данные восстановлены из защищённой резервной копии."; + await SaveAsync(recovered); + return recovered; } throw new InvalidDataException( $"Хранилище терминала повреждено. Исходный файл сохранён как {Path.GetFileName(corruptPath)}. Работа заблокирована до восстановления резервной копии.", primaryException); } + catch (IOException exception) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + "Хранилище терминала временно недоступно. Файл не изменён; закройте программу, которая его удерживает, и повторите запуск.", + exception); + } + catch (UnauthorizedAccessException exception) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException( + "Нет доступа к хранилищу терминала. Файл не изменён; проверьте права текущего пользователя.", + exception); + } } + private async Task TryRecoverFromBackupAsync() + { + foreach (var backupPath in BackupPaths()) + { + if (!File.Exists(backupPath)) continue; + try + { + var recovered = await ReadStateAsync(backupPath); + MigrateFingerprints(recovered); + return recovered; + } + catch (Exception backupException) when (IsCorruptStateException(backupException)) + { + CrashLogService.Write(backupException); + } + } + return null; + } + + private static bool IsCorruptStateException(Exception exception) => + exception is CryptographicException or JsonException or InvalidDataException or NotSupportedException; + private static async Task ReadStateAsync(string path) { var encrypted = await File.ReadAllBytesAsync(path); var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); - return JsonSerializer.Deserialize(clear, JsonOptions) - ?? throw new InvalidDataException("Файл состояния не содержит данных."); + var state = JsonSerializer.Deserialize(clear, JsonOptions) + ?? throw new InvalidDataException("Файл состояния не содержит данных."); + if (state.Employees is null || state.Attendance is null || state.SecurityAudit is null || state.Settings is null) + { + throw new InvalidDataException("Файл состояния имеет неполную структуру."); + } + return state; } private static bool MigrateFingerprints(TerminalState state) @@ -101,7 +162,7 @@ public sealed class TerminalStore return changed; } - public async Task SaveAsync(TerminalState state) + public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false) { await _saveGate.WaitAsync(); try @@ -124,8 +185,25 @@ public sealed class TerminalStore stream.Flush(true); } - RotateBackups(); + string? previousStatePath = null; + if (File.Exists(_filePath) && ShouldCreateRecoveryPoint(createRecoveryPoint)) + { + previousStatePath = _filePath + ".previous"; + File.Copy(_filePath, previousStatePath, true); + } File.Move(temporaryPath, _filePath, true); + MarkInitialized(); + if (previousStatePath is not null) + { + try + { + RotateBackups(previousStatePath); + } + finally + { + File.Delete(previousStatePath); + } + } } finally { @@ -133,19 +211,48 @@ public sealed class TerminalStore } } + private bool ShouldCreateRecoveryPoint(bool requested) + { + var latestBackup = BackupPaths().First(); + var minimumInterval = requested ? TimeSpan.FromHours(24) : AutomaticRecoveryPointInterval; + return !File.Exists(latestBackup) || + DateTimeOffset.UtcNow - File.GetLastWriteTimeUtc(latestBackup) >= minimumInterval; + } + private IEnumerable BackupPaths() { for (var index = 1; index <= 3; index++) yield return _filePath + $".bak{index}"; } - private void RotateBackups() + private void RotateBackups(string previousStatePath) { var backups = BackupPaths().ToArray(); for (var index = backups.Length - 1; index > 0; index--) { if (File.Exists(backups[index - 1])) File.Copy(backups[index - 1], backups[index], true); } - if (File.Exists(_filePath)) File.Copy(_filePath, backups[0], true); + File.Copy(previousStatePath, backups[0], true); + } + + private bool HasRecoveryArtifacts() + { + if (BackupPaths().Any(File.Exists) || File.Exists(_filePath + ".tmp") || File.Exists(_filePath + ".previous")) return true; + var directory = Path.GetDirectoryName(_filePath)!; + return Directory.Exists(directory) && + Directory.EnumerateFiles(directory, "terminal-state.dat.corrupt-*").Any(); + } + + private static bool WasPreviouslyInitialized() + { + using var key = Registry.CurrentUser.OpenSubKey(RegistryPath); + return key?.GetValue(InitializedValueName) is int value && value == 1; + } + + private static void MarkInitialized() + { + using var key = Registry.CurrentUser.CreateSubKey(RegistryPath, writable: true) + ?? throw new InvalidOperationException("Не удалось открыть защищённый маркер настройки терминала."); + key.SetValue(InitializedValueName, 1, RegistryValueKind.DWord); } private static string? FindLegacyPackagedState() diff --git a/compose.yaml b/compose.yaml index 4bdf899..e425fb9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,30 +1,52 @@ services: app: build: . - env_file: .env environment: + NODE_ENV: ${NODE_ENV:-development} + PORT: ${PORT:-8080} + LOG_LEVEL: ${LOG_LEVEL:-info} + STAFF_RUNTIME_MODE: ${STAFF_RUNTIME_MODE:-standalone} DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD:-local-app-only}@postgres:5432/krona_staff + PLATFORM_GATEWAY_URL: ${PLATFORM_GATEWAY_URL:-} + PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-} + PLATFORM_JWT_AUDIENCE: ${PLATFORM_JWT_AUDIENCE:-} + PLATFORM_JWKS_URL: ${PLATFORM_JWKS_URL:-} + STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:-01J00000000000000000000000} + STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:-01J00000000000000000000001} ports: ['8080:8080'] depends_on: - postgres: { condition: service_healthy } + migrate: { condition: service_completed_successfully } restart: unless-stopped read_only: true tmpfs: [/tmp] security_opt: ['no-new-privileges:true'] - migrate: + healthcheck: + test: ['CMD-SHELL', 'wget -q -O - http://127.0.0.1:8080/api/v1/staff/live || exit 1'] + interval: 30s + timeout: 3s + retries: 3 + db-role: build: { context: ., target: build } - env_file: .env environment: - DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}@postgres:5432/krona_staff - command: ['node', 'scripts/migrate.mjs', 'up'] + DATABASE_MIGRATION_URL: postgres://${POSTGRES_MIGRATION_USER:-krona_migrator}:${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}@postgres:5432/krona_staff + POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:-local-app-only} + command: ['node', 'scripts/create-app-role.mjs'] depends_on: postgres: { condition: service_healthy } - profiles: ['tools'] + restart: 'no' + migrate: + build: { context: ., target: build } + environment: + DATABASE_MIGRATION_URL: postgres://${POSTGRES_MIGRATION_USER:-krona_migrator}:${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}@postgres:5432/krona_staff + command: ['node', 'scripts/migrate.mjs', 'up'] + depends_on: + db-role: { condition: service_completed_successfully } + restart: 'no' postgres: image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73 environment: POSTGRES_DB: krona_staff - POSTGRES_USER: krona_migrator + POSTGRES_USER: ${POSTGRES_MIGRATION_USER:-krona_migrator} POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:-local-migration-only} POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:-local-app-only} volumes: @@ -32,7 +54,8 @@ services: - ./docker/postgres/init-app-role.sh:/docker-entrypoint-initdb.d/10-app-role.sh:ro healthcheck: { - test: ['CMD-SHELL', 'pg_isready -U krona_migrator -d krona_staff'], + test: + ['CMD-SHELL', 'pg_isready -U ${POSTGRES_MIGRATION_USER:-krona_migrator} -d krona_staff'], interval: 5s, timeout: 3s, retries: 20, diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 5624dc6..729734f 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,9 +1,11 @@ # Развёртывание -Одна команда локально: скопировать `.env.example` в `.env`, затем `docker compose --profile tools run --rm migrate` и `docker compose up --build`. Compose поднимает приложение и PostgreSQL. Контейнер PostgreSQL создаёт отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`; миграции выполняются только ролью `krona_migrator`. Redis и object storage не включаются до появления реального сценария очередей или файлов. +Локально скопируйте `.env.example` в `.env` и выполните `docker compose up --build`. Compose последовательно проверит PostgreSQL, создаст или обновит отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`, выполнит миграции ролью `krona_migrator` и только затем запустит приложение. Runtime-контейнер не получает пароль или URL мигратора. Redis и object storage не включаются до появления реального сценария очередей или файлов. + +Для обновления старого Docker volume, созданного версией с ролью `krona_staff`, задайте в `.env` `POSTGRES_MIGRATION_USER=krona_staff` и прежний `POSTGRES_MIGRATION_PASSWORD`, затем выполните обычный `docker compose up --build`. Сохраняйте эту роль владельцем миграций для данного volume: автоматического переименования владельца существующей базы нет, а перенос владения требует отдельного DBA-плана с повторной настройкой default privileges. Production: один stateless app container/process, отдельный PostgreSQL, TLS/reverse proxy, secret manager и централизованные logs/metrics/traces. Приложению выдаётся только роль без `SUPERUSER` и `BYPASSRLS`; миграционный URL хранится отдельно и не передаётся runtime-контейнеру. `/api/v1/staff/live` проверяет процесс, `/api/v1/staff/ready` — обязательные зависимости, `/api/v1/staff/version` — release и runtime mode. Container работает non-root, read-only filesystem. HA сейчас не требуется. Для будущего scale-out: общий PostgreSQL/Redis/S3, несколько API/worker nodes, leader election для scheduler, outbox relay и идемпотентные consumers. Локальные вложения запрещены. Linux и Windows deployment сохраняют паритет на уровне Node/container contract. -Миграции запускаются отдельным one-shot job до rollout, учитываются в `schema_migrations` и защищены advisory lock. Down откатывает только последнюю миграцию и требует backup плюс `ALLOW_MIGRATION_DOWN=true`. +Миграции запускаются отдельным one-shot job до rollout, учитываются в `schema_migrations` вместе с SHA-256 исходного SQL и защищены advisory lock. Для старых записей без checksum первый запуск фиксирует текущую версию как доверенную базовую; последующие изменения применённого SQL блокируются. Down откатывает только последнюю миграцию и требует backup плюс `ALLOW_MIGRATION_DOWN=true`. diff --git a/docs/adr/ADR-001-technology-and-runtime-modes.md b/docs/adr/ADR-001-technology-and-runtime-modes.md index 506e69a..c658d8e 100644 --- a/docs/adr/ADR-001-technology-and-runtime-modes.md +++ b/docs/adr/ADR-001-technology-and-runtime-modes.md @@ -5,11 +5,11 @@ ## Context -Platform contracts are TypeScript, baseline API is REST/OpenAPI 3.1, PostgreSQL/Redis/BullMQ are normative, blocks need independent deployment without duplicating Core identity. Staff business rules are unknown. +Platform contracts are TypeScript, baseline API is REST/OpenAPI 3.1, PostgreSQL is the current durable store, and blocks need independent deployment without duplicating Core identity. Staff business rules are unknown. Redis/BullMQ are intentionally deferred until a confirmed queue or worker scenario exists. ## Decision -Use Node.js 22 + strict TypeScript, Fastify, PostgreSQL 17, Redis/BullMQ, Zod, Vitest and a single stateless container. Keep domain code behind ports. Select either standalone adapters or platform-gateway adapters through configuration. Use ULID `VARCHAR(26)`, UTC, `BIGINT` minor money, transactional outbox/inbox and S3 abstraction. +Use Node.js 22 + strict TypeScript, Fastify, PostgreSQL 17, Zod, Vitest and a single stateless container. Keep domain code behind ports. Select either standalone adapters or platform-gateway adapters through configuration. Use ULID `VARCHAR(26)`, UTC, `BIGINT` minor money, transactional outbox/inbox and S3 abstraction. Add Redis/BullMQ only with a confirmed workload and a separate ADR. Standalone provides only explicit development tenant/actor references; no durable user/role source. Platform mode fails closed until verified Core JWT/gateway contracts are implemented. diff --git a/migrations/0003_app_role_privileges.down.sql b/migrations/0003_app_role_privileges.down.sql new file mode 100644 index 0000000..1c20aaa --- /dev/null +++ b/migrations/0003_app_role_privileges.down.sql @@ -0,0 +1,4 @@ +ALTER DEFAULT PRIVILEGES IN SCHEMA public + REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM krona_staff_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + REVOKE USAGE, SELECT ON SEQUENCES FROM krona_staff_app; diff --git a/migrations/0003_app_role_privileges.up.sql b/migrations/0003_app_role_privileges.up.sql new file mode 100644 index 0000000..da9017a --- /dev/null +++ b/migrations/0003_app_role_privileges.up.sql @@ -0,0 +1,17 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_staff_app') THEN + RAISE EXCEPTION 'Required database role krona_staff_app does not exist. Run scripts/create-app-role.mjs before migrations.'; + END IF; +END +$$; + +GRANT USAGE ON SCHEMA public TO krona_staff_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON staff_outbox, staff_inbox, staff_idempotency_keys TO krona_staff_app; +GRANT SELECT, INSERT ON staff_audit_log TO krona_staff_app; +REVOKE UPDATE, DELETE, TRUNCATE ON staff_audit_log FROM krona_staff_app; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO krona_staff_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO krona_staff_app; diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 2edf1c3..b8d1046 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -1,6 +1,11 @@ import { readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import pg from 'pg'; +import { + assertAppliedMigrationsPresent, + assertMigrationChecksum, + migrationChecksum, +} from './migration-checksum.mjs'; const direction = process.argv[2]; if (direction !== 'up' && direction !== 'down') { @@ -28,27 +33,50 @@ try { await client.query(` CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, + checksum TEXT, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `); + await client.query('ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS checksum TEXT'); if (direction === 'up') { - const appliedResult = await client.query('SELECT version FROM schema_migrations'); - const applied = new Set(appliedResult.rows.map((row) => String(row.version))); + const appliedResult = await client.query('SELECT version, checksum FROM schema_migrations'); + const applied = new Map( + appliedResult.rows.map((row) => [String(row.version), row.checksum && String(row.checksum)]), + ); + const availableVersions = new Set(files.map((file) => file.replace(/\.up\.sql$/, ''))); + assertAppliedMigrationsPresent(applied.keys(), availableVersions); for (const file of files) { const version = file.replace(/\.up\.sql$/, ''); - if (applied.has(version)) continue; - const sql = await readFile(path.join(migrationsDirectory, file), 'utf8'); + const content = await readFile(path.join(migrationsDirectory, file)); + const checksum = migrationChecksum(content); + if (applied.has(version)) { + const storedChecksum = applied.get(version); + if (storedChecksum) { + assertMigrationChecksum(version, storedChecksum, checksum); + } else { + await client.query('UPDATE schema_migrations SET checksum = $2 WHERE version = $1', [ + version, + checksum, + ]); + console.warn(`baselined checksum for legacy migration ${version}`); + } + continue; + } + const sql = content.toString('utf8'); await applyTransaction(async () => { await client.query(sql); - await client.query('INSERT INTO schema_migrations (version) VALUES ($1)', [version]); + await client.query('INSERT INTO schema_migrations (version, checksum) VALUES ($1, $2)', [ + version, + checksum, + ]); }); console.log(`applied ${file}`); } } else { const requestedVersion = process.argv[3]; const latestResult = await client.query( - 'SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1', + 'SELECT version, checksum FROM schema_migrations ORDER BY version DESC LIMIT 1', ); const latestVersion = latestResult.rows[0]?.version; if (!latestVersion) { @@ -60,6 +88,18 @@ try { ); } const downFile = `${latestVersion}.down.sql`; + const upContent = await readFile(path.join(migrationsDirectory, `${latestVersion}.up.sql`)); + const currentChecksum = migrationChecksum(upContent); + if (!latestResult.rows[0].checksum) { + throw new Error( + `Migration ${latestVersion} has no checksum. Run the up command once to establish the legacy baseline before rollback.`, + ); + } + assertMigrationChecksum( + latestVersion, + String(latestResult.rows[0].checksum), + currentChecksum, + ); const sql = await readFile(path.join(migrationsDirectory, downFile), 'utf8'); await applyTransaction(async () => { await client.query(sql); diff --git a/scripts/migration-checksum.d.mts b/scripts/migration-checksum.d.mts new file mode 100644 index 0000000..e1b84d4 --- /dev/null +++ b/scripts/migration-checksum.d.mts @@ -0,0 +1,6 @@ +export function migrationChecksum(content: Uint8Array): string; +export function assertMigrationChecksum(version: string, expected: string, actual: string): void; +export function assertAppliedMigrationsPresent( + appliedVersions: Iterable, + availableVersions: Iterable, +): void; diff --git a/scripts/migration-checksum.mjs b/scripts/migration-checksum.mjs new file mode 100644 index 0000000..83669ca --- /dev/null +++ b/scripts/migration-checksum.mjs @@ -0,0 +1,22 @@ +import { createHash } from 'node:crypto'; + +export function migrationChecksum(content) { + return createHash('sha256').update(content).digest('hex'); +} + +export function assertMigrationChecksum(version, expected, actual) { + if (expected !== actual) { + throw new Error( + `Applied migration ${version} was modified: expected sha256 ${expected}, got ${actual}`, + ); + } +} + +export function assertAppliedMigrationsPresent(appliedVersions, availableVersions) { + const available = new Set(availableVersions); + for (const version of appliedVersions) { + if (!available.has(version)) { + throw new Error(`Applied migration ${version} is missing from the repository`); + } + } +} diff --git a/src/config.ts b/src/config.ts index a0f7c18..9193e8e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,15 +6,23 @@ const configSchema = z.object({ LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'), STAFF_RUNTIME_MODE: z.enum(['standalone', 'platform']).default('standalone'), DATABASE_URL: z.string().url(), - REDIS_URL: z.string().url().optional(), - PLATFORM_GATEWAY_URL: z.string().url().optional(), - PLATFORM_JWT_ISSUER: z.string().url().optional(), - PLATFORM_JWT_AUDIENCE: z.string().optional(), - PLATFORM_JWKS_URL: z.string().url().optional(), + REDIS_URL: optionalUrl(), + PLATFORM_GATEWAY_URL: optionalUrl(), + PLATFORM_JWT_ISSUER: optionalUrl(), + PLATFORM_JWT_AUDIENCE: emptyStringAsUndefined(z.string().optional()), + PLATFORM_JWKS_URL: optionalUrl(), STANDALONE_TENANT_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/), STANDALONE_ACTOR_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/), }); +function emptyStringAsUndefined(schema: T) { + return z.preprocess((value) => (value === '' ? undefined : value), schema); +} + +function optionalUrl() { + return emptyStringAsUndefined(z.string().url().optional()); +} + export type AppConfig = z.infer; export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index b672f62..8e1f501 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -18,4 +18,14 @@ describe('runtime modes', () => { expect(() => loadConfig({ ...base, NODE_ENV: 'production' })).toThrow( /standalone is not allowed/, )); + it('treats empty optional platform variables as absent in compose', () => + expect( + loadConfig({ + ...base, + PLATFORM_GATEWAY_URL: '', + PLATFORM_JWT_ISSUER: '', + PLATFORM_JWT_AUDIENCE: '', + PLATFORM_JWKS_URL: '', + }).PLATFORM_GATEWAY_URL, + ).toBeUndefined()); }); diff --git a/tests/unit/deployment.test.ts b/tests/unit/deployment.test.ts new file mode 100644 index 0000000..3b79609 --- /dev/null +++ b/tests/unit/deployment.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; + +describe('container deployment boundaries', () => { + const compose = parse(readFileSync('compose.yaml', 'utf8')) as { + services: Record; depends_on?: unknown }>; + }; + + it('probes the versioned liveness route', () => { + expect(readFileSync('Dockerfile', 'utf8')).toContain('/api/v1/staff/live'); + expect(readFileSync('Dockerfile', 'utf8')).not.toMatch(/8080\/live\b/); + }); + + it('does not expose migration credentials to the runtime service', () => { + const environment = compose.services.app?.environment ?? {}; + expect(environment).not.toHaveProperty('DATABASE_MIGRATION_URL'); + expect(environment).not.toHaveProperty('POSTGRES_MIGRATION_PASSWORD'); + expect(environment).not.toHaveProperty('POSTGRES_APP_PASSWORD'); + }); + + it('provisions the app role and migrates before app startup', () => { + expect(compose.services['db-role']).toBeDefined(); + expect(compose.services.migrate?.depends_on).toHaveProperty('db-role'); + expect(compose.services.app?.depends_on).toHaveProperty('migrate'); + }); +}); diff --git a/tests/unit/migration-checksum.test.ts b/tests/unit/migration-checksum.test.ts new file mode 100644 index 0000000..4a6e0dd --- /dev/null +++ b/tests/unit/migration-checksum.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { + assertAppliedMigrationsPresent, + assertMigrationChecksum, + migrationChecksum, +} from '../../scripts/migration-checksum.mjs'; + +describe('migration checksums', () => { + it('is stable for the exact migration bytes', () => { + const content = Buffer.from('SELECT 1;\n', 'utf8'); + expect(migrationChecksum(content)).toBe(migrationChecksum(content)); + }); + + it('rejects a modified applied migration', () => { + const expected = migrationChecksum(Buffer.from('SELECT 1;\n', 'utf8')); + const actual = migrationChecksum(Buffer.from('SELECT 2;\n', 'utf8')); + expect(() => assertMigrationChecksum('0001', expected, actual)).toThrow( + /Applied migration 0001 was modified/, + ); + }); + + it('rejects an applied migration removed from the repository', () => { + expect(() => assertAppliedMigrationsPresent(['0001', '0002'], ['0002'])).toThrow( + /Applied migration 0001 is missing/, + ); + }); +});