diff --git a/Core/Resgrid.Config/ApiConfig.cs b/Core/Resgrid.Config/ApiConfig.cs index 421a7bfe1..c60b27e26 100644 --- a/Core/Resgrid.Config/ApiConfig.cs +++ b/Core/Resgrid.Config/ApiConfig.cs @@ -20,7 +20,8 @@ public static class ApiConfig /// to the API and eventing hubs, on top of the configured base urls, their subdomains and /// their shared parent domain (see Resgrid.Config.CorsHelper). Entries with a scheme match /// the exact origin ("http://localhost:8081"); bare hosts match that host on any scheme and - /// port ("dispatch.example.com"). A single "*" allows every origin — intended only for + /// port ("dispatch.example.com"); wildcard hosts match the apex and every subdomain on any + /// scheme and port ("*.resgrid.com"). A single "*" allows every origin — intended only for /// isolated on-prem or development installs. /// public static string CorsAllowedOrigins = ""; diff --git a/Core/Resgrid.Config/CorsHelper.cs b/Core/Resgrid.Config/CorsHelper.cs index 5f180c980..57a986b9f 100644 --- a/Core/Resgrid.Config/CorsHelper.cs +++ b/Core/Resgrid.Config/CorsHelper.cs @@ -8,8 +8,11 @@ namespace Resgrid.Config /// An origin is allowed when it matches any of: /// 1. An entry in . Entries with a scheme /// ("http://localhost:8081") must match the origin's scheme, host and port exactly; bare - /// hosts ("dispatch.example.com") match that host on any scheme/port. A single "*" entry - /// allows every origin and is intended only for isolated on-prem or development installs. + /// hosts ("dispatch.example.com") match that host on any scheme/port; wildcard hosts + /// ("*.resgrid.com") match the apex and every subdomain on any scheme/port. An entry equal + /// to the raw Origin header value also matches verbatim, which covers desktop-app origins + /// like Electron's "app://." that are not standard URIs. A single "*" entry allows every + /// origin and is intended only for isolated on-prem or development installs. /// 2. The host of one of the configured base urls (ResgridBaseUrl, ResgridApiBaseUrl, /// ResgridEventingBaseUrl), or any subdomain of one of those hosts. /// 3. The widest safe parent domain of a base-url host, or any subdomain of it. This is what @@ -82,7 +85,16 @@ public static class CorsHelper /// public static bool IsAllowedOrigin(string origin) { - if (String.IsNullOrWhiteSpace(origin) || !Uri.TryCreate(origin, UriKind.Absolute, out var originUri) || String.IsNullOrWhiteSpace(originUri.Host)) + if (String.IsNullOrWhiteSpace(origin)) + return false; + + // Verbatim config match before URI parsing: desktop-app origins such as + // Electron's custom-scheme "app://." are not reliably parseable as absolute + // URIs, so an exact entry must be honored without going through Uri. + if (MatchesConfiguredOriginVerbatim(origin)) + return true; + + if (!Uri.TryCreate(origin, UriKind.Absolute, out var originUri) || String.IsNullOrWhiteSpace(originUri.Host)) return false; if (MatchesConfiguredOrigin(originUri)) @@ -109,6 +121,25 @@ public static bool IsAllowedOrigin(string origin) return false; } + private static bool MatchesConfiguredOriginVerbatim(string origin) + { + var configured = ApiConfig.CorsAllowedOrigins; + if (String.IsNullOrWhiteSpace(configured)) + return false; + + foreach (var rawEntry in configured.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + var entry = rawEntry.Trim(); + if (entry.Length == 0) + continue; + + if (entry == "*" || String.Equals(entry, origin, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + private static bool MatchesConfiguredOrigin(Uri originUri) { var configured = ApiConfig.CorsAllowedOrigins; @@ -124,6 +155,17 @@ private static bool MatchesConfiguredOrigin(Uri originUri) if (entry == "*") return true; + if (entry.StartsWith("*.", StringComparison.Ordinal)) + { + // Wildcard host: "*.resgrid.com" allows the apex and every subdomain, + // on any scheme and port. + var suffix = entry.Substring(2); + if (suffix.Length > 0 && HostMatchesOrIsSubdomainOf(originUri.Host, suffix)) + return true; + + continue; + } + if (entry.Contains("://")) { if (Uri.TryCreate(entry, UriKind.Absolute, out var entryUri) && diff --git a/Core/Resgrid.Framework/Logging.cs b/Core/Resgrid.Framework/Logging.cs index c6b768b0f..60a4ec754 100644 --- a/Core/Resgrid.Framework/Logging.cs +++ b/Core/Resgrid.Framework/Logging.cs @@ -108,6 +108,16 @@ public static void LogError(string message) } + public static void LogWarning(string message) + { + Initialize(null); + + + if (_logger != null) + _logger.Warning(message); + + } + public static void LogDebug(string message) { Initialize(null); diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.ar.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.ar.resx index f6cdac500..40acf473f 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.ar.resx @@ -135,6 +135,9 @@ يُرجى ملاحظة أن البيانات المقدمة إلى Resgrid لقسم ما تخص ذلك القسم وقد تخضع لسياسات الاحتفاظ بالبيانات والقوانين السارية في نطاق القسم. سيؤدي حذف حسابك إلى مسح معلوماتك الشخصية (PII) وحذف بيانات تسجيل الدخول، لكنه لن يمسح جميع البيانات. ستحتاج إلى تقديم طلب إلى القسم الذي كنت منتمياً إليه إذا كنت تريد مسح جميع البيانات. + + سيؤدي حذف حسابك إلى إلغاء تنشيطه في جميع الأقسام التي أنت عضو فيها، وليس فقط القسم الحالي. ستتم إزالة جميع الأتمتة المجدولة الخاصة بك (تسليم التقارير، وتغييرات الحالة المجدولة، وتغييرات التوظيف المجدولة)، وستتوقف عن تلقي الإشعارات والمراسلات من جميع الأقسام. + لا يمكنك حذف حسابك لأنك مالك قسم. إذا كنت ترغب في حذف القسم، ستحتاج إلى الوصول إليه عبر صفحة إعدادات القسم. إذا كنت تريد الاحتفاظ بالقسم، فاختر شخصاً آخر ليكون مالكاً للقسم ثم حاول حذف حسابك مرة أخرى. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.de.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.de.resx index 587bb42c3..c48907c6a 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.de.resx @@ -86,6 +86,9 @@ Please note, data supplied to Resgrid for a department is owned by that department and could be subject to data retention policies and laws governing the jurisdiction of the department. Deleting your account here will clear your PII (Personally Identifiable Information) and delete your login but won’t clear all data. You will need to make a request to the department you were apart of for additional if you want all data cleared. + + Wenn Sie Ihr Konto löschen, wird es in JEDER Abteilung deaktiviert, in der Sie Mitglied sind - nicht nur in Ihrer aktuellen. Alle Ihre geplanten Automatisierungen (Berichtszustellungen, geplante Statusänderungen und geplante Besetzungsänderungen) werden entfernt, und Sie erhalten keine Benachrichtigungen und Mitteilungen mehr von allen Abteilungen. + You are unable to delete your account because you are a Department Owner in a department. If you wish to delete the department you will need to access that though the Department Settings page. If you want to retain the department please choose another person as the Department Owner and then try and delete your account again. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.en.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.en.resx index 24ab235e3..ab659339e 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.en.resx @@ -135,6 +135,9 @@ Please note, data supplied to Resgrid for a department is owned by that department and could be subject to data retention policies and laws governing the jurisdiction of the department. Deleting your account here will clear your PII (Personally Identifiable Information) and delete your login but won’t clear all data. You will need to make a request to the department you were apart of for additional if you want all data cleared. + + Deleting your account will deactivate it in EVERY department you are a member of, not just your current one. All of your scheduled automations (report deliveries, scheduled status changes and scheduled staffing changes) will be removed, and you will stop receiving notifications and communications from all departments. + You are unable to delete your account because you are a Department Owner in a department. If you wish to delete the department you will need to access that though the Department Settings page. If you want to retain the department please choose another person as the Department Owner and then try and delete your account again. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.es.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.es.resx index 0221c694b..f1e14e2da 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.es.resx @@ -86,6 +86,9 @@ Ten en cuenta que los datos suministrados a Resgrid para un departamento son propiedad de ese departamento y pueden estar sujetos a políticas de retención de datos. Eliminar tu cuenta aquí borrará tu información personal (PII) y tu acceso, pero no todos los datos. + + Eliminar su cuenta la desactivará en TODOS los departamentos de los que sea miembro, no solo en el actual. Todas sus automatizaciones programadas (entregas de informes, cambios de estado programados y cambios de personal programados) serán eliminadas, y dejará de recibir notificaciones y comunicaciones de todos los departamentos. + No puedes eliminar tu cuenta porque eres propietario de un departamento. Si deseas eliminar el departamento, debes acceder a eso a través de la página de configuración del departamento. Si deseas conservar el departamento, elige a otra persona como propietario del departamento y luego intenta eliminar tu cuenta. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.fr.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.fr.resx index d6c65abdb..39e40b83f 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.fr.resx @@ -86,6 +86,9 @@ Please note, data supplied to Resgrid for a department is owned by that department and could be subject to data retention policies and laws governing the jurisdiction of the department. Deleting your account here will clear your PII (Personally Identifiable Information) and delete your login but won’t clear all data. You will need to make a request to the department you were apart of for additional if you want all data cleared. + + La suppression de votre compte le désactivera dans TOUS les départements dont vous êtes membre, pas seulement dans votre département actuel. Toutes vos automatisations planifiées (envois de rapports, changements de statut planifiés et changements d'effectifs planifiés) seront supprimées, et vous ne recevrez plus de notifications ni de communications d'aucun département. + You are unable to delete your account because you are a Department Owner in a department. If you wish to delete the department you will need to access that though the Department Settings page. If you want to retain the department please choose another person as the Department Owner and then try and delete your account again. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.it.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.it.resx index 0d2afe3af..24bd2b0e8 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.it.resx @@ -86,6 +86,9 @@ Please note, data supplied to Resgrid for a department is owned by that department and could be subject to data retention policies and laws governing the jurisdiction of the department. Deleting your account here will clear your PII (Personally Identifiable Information) and delete your login but won’t clear all data. You will need to make a request to the department you were apart of for additional if you want all data cleared. + + L'eliminazione del tuo account lo disattiverà in TUTTI i dipartimenti di cui sei membro, non solo in quello corrente. Tutte le tue automazioni pianificate (invii di report, cambi di stato pianificati e modifiche pianificate del personale) verranno rimosse e smetterai di ricevere notifiche e comunicazioni da tutti i dipartimenti. + You are unable to delete your account because you are a Department Owner in a department. If you wish to delete the department you will need to access that though the Department Settings page. If you want to retain the department please choose another person as the Department Owner and then try and delete your account again. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.pl.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.pl.resx index 6d7f957df..e1d04e03f 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.pl.resx @@ -86,6 +86,9 @@ Please note, data supplied to Resgrid for a department is owned by that department and could be subject to data retention policies and laws governing the jurisdiction of the department. Deleting your account here will clear your PII (Personally Identifiable Information) and delete your login but won’t clear all data. You will need to make a request to the department you were apart of for additional if you want all data cleared. + + Usunięcie konta spowoduje jego dezaktywację w KAŻDYM dziale, którego jesteś członkiem, nie tylko w bieżącym. Wszystkie zaplanowane automatyzacje (dostarczanie raportów, zaplanowane zmiany statusu i zaplanowane zmiany obsady) zostaną usunięte, a Ty przestaniesz otrzymywać powiadomienia i komunikaty ze wszystkich działów. + You are unable to delete your account because you are a Department Owner in a department. If you wish to delete the department you will need to access that though the Department Settings page. If you want to retain the department please choose another person as the Department Owner and then try and delete your account again. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.sv.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.sv.resx index 521cdc08d..be90c45fe 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.sv.resx @@ -86,6 +86,9 @@ Please note, data supplied to Resgrid for a department is owned by that department and could be subject to data retention policies and laws governing the jurisdiction of the department. Deleting your account here will clear your PII (Personally Identifiable Information) and delete your login but won’t clear all data. You will need to make a request to the department you were apart of for additional if you want all data cleared. + + Om du raderar ditt konto inaktiveras det i ALLA avdelningar du är medlem i, inte bara din nuvarande. Alla dina schemalagda automatiseringar (rapportleveranser, schemalagda statusändringar och schemalagda bemanningsändringar) tas bort, och du slutar få aviseringar och meddelanden från alla avdelningar. + You are unable to delete your account because you are a Department Owner in a department. If you wish to delete the department you will need to access that though the Department Settings page. If you want to retain the department please choose another person as the Department Owner and then try and delete your account again. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.uk.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.uk.resx index 7ab7f877b..d3a03e0bc 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.uk.resx @@ -86,6 +86,9 @@ Please note, data supplied to Resgrid for a department is owned by that department and could be subject to data retention policies and laws governing the jurisdiction of the department. Deleting your account here will clear your PII (Personally Identifiable Information) and delete your login but won’t clear all data. You will need to make a request to the department you were apart of for additional if you want all data cleared. + + Видалення облікового запису деактивує його в УСІХ підрозділах, учасником яких ви є, а не лише в поточному. Усі ваші заплановані автоматизації (доставка звітів, заплановані зміни статусу та заплановані зміни укомплектування) будуть видалені, і ви більше не отримуватимете сповіщення та повідомлення від жодного підрозділу. + You are unable to delete your account because you are a Department Owner in a department. If you wish to delete the department you will need to access that though the Department Settings page. If you want to retain the department please choose another person as the Department Owner and then try and delete your account again. diff --git a/Core/Resgrid.Model/Repositories/IScheduledTasksRepository.cs b/Core/Resgrid.Model/Repositories/IScheduledTasksRepository.cs index 9326adac3..65eb9792c 100644 --- a/Core/Resgrid.Model/Repositories/IScheduledTasksRepository.cs +++ b/Core/Resgrid.Model/Repositories/IScheduledTasksRepository.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Resgrid.Model.Repositories @@ -18,5 +19,18 @@ public interface IScheduledTasksRepository: IRepository Task> GetAllActiveTasksForTypesAsync(List types); Task> GetAllUpcomingOrRecurringReportDeliveryTasksAsync(); + + /// + /// Deletes every scheduled task (and their logs) owned by a user, across all departments. + /// Used when a user account is deleted/deactivated. + /// + Task DeleteAllTasksForUserAsync(string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Deletes every scheduled task (and their logs) owned by a user that is scoped to a single + /// department. Legacy rows with DepartmentId = 0 are left alone; the active-task queries + /// resolve those through non-deleted department memberships. + /// + Task DeleteAllTasksForUserInDepartmentAsync(string userId, int departmentId, CancellationToken cancellationToken = default(CancellationToken)); } } diff --git a/Core/Resgrid.Model/Services/IDeleteService.cs b/Core/Resgrid.Model/Services/IDeleteService.cs index ce79491d6..aa06d8807 100644 --- a/Core/Resgrid.Model/Services/IDeleteService.cs +++ b/Core/Resgrid.Model/Services/IDeleteService.cs @@ -9,13 +9,24 @@ namespace Resgrid.Model.Services public interface IDeleteService { /// - /// Deletes the user asynchronous. + /// Removes a user from a department (admin initiated). If the user belongs to other + /// departments only this department's access, roles, groups, lists and automations are + /// revoked and the account stays usable; if this is their only department the whole + /// account is deactivated using the same flow as the self-service account delete. /// /// The department identifier. /// The authorizing user identifier. /// The user identifier to delete. /// Task<DeleteUserResults>. - Task DeleteUserAsync(int departmentId, string authorizingUserId, string userIdToDelete); + Task DeleteUserAsync(int departmentId, string authorizingUserId, string userIdToDelete, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Revokes a user's access to a single department without touching their account, login + /// or PII: removes roles, group memberships, distribution list subscriptions and scheduled + /// automations for that department, then soft-deletes the membership. No authorization + /// check is performed; callers are responsible for authorizing the operation. + /// + Task RevokeDepartmentAccessAsync(string userId, int departmentId, string revokingUserId, CancellationToken cancellationToken = default(CancellationToken)); /// /// Deletes the group asynchronous. diff --git a/Core/Resgrid.Model/Services/IDistributionListsService.cs b/Core/Resgrid.Model/Services/IDistributionListsService.cs index 4c46199ad..c1ccd6f92 100644 --- a/Core/Resgrid.Model/Services/IDistributionListsService.cs +++ b/Core/Resgrid.Model/Services/IDistributionListsService.cs @@ -73,5 +73,11 @@ public interface IDistributionListsService /// The cancellation token that can be used by other objects or threads to receive notice of cancellation. /// Task<System.Boolean>. Task RemoveUserFromAllListsAsync(string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Removes a user from every distribution list belonging to a single department. Used when a + /// user is removed from one department but remains in others. + /// + Task RemoveUserFromAllListsInDepartmentAsync(string userId, int departmentId, CancellationToken cancellationToken = default(CancellationToken)); } } diff --git a/Core/Resgrid.Model/Services/IScheduledTasksService.cs b/Core/Resgrid.Model/Services/IScheduledTasksService.cs index 4f3bd0ed5..de4960ff6 100644 --- a/Core/Resgrid.Model/Services/IScheduledTasksService.cs +++ b/Core/Resgrid.Model/Services/IScheduledTasksService.cs @@ -156,5 +156,17 @@ Task CreateScheduleTaskLogAsync(ScheduledTask task, Task> GetAllUpcomingStatusScheduledTasksAsync(); Task> GetAllUpcomingOrRecurringReportDeliveryTasksAsync(); + + /// + /// Deletes every scheduled task (status changes, staffing changes, report deliveries) owned + /// by a user across all departments. Used when a user account is deleted/deactivated. + /// + Task DeleteAllTasksForUserAsync(string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Deletes every scheduled task owned by a user that is scoped to a single department. Used + /// when a user is removed from one department but remains in others. + /// + Task DeleteAllTasksForUserInDepartmentAsync(string userId, int departmentId, CancellationToken cancellationToken = default(CancellationToken)); } } diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs index 6393dd053..805209e69 100644 --- a/Core/Resgrid.Services/DeleteService.cs +++ b/Core/Resgrid.Services/DeleteService.cs @@ -38,6 +38,7 @@ public class DeleteService : IDeleteService private readonly IEmailService _emailService; private readonly IDeleteRepository _deleteRepository; private readonly IAuditLogsRepository _auditLogsRepository; + private readonly IScheduledTasksService _scheduledTasksService; public DeleteService(IAuthorizationService authorizationService, IDepartmentsService departmentsService, ICallsService callsService, IActionLogsService actionLogsService, IUsersService usersService, @@ -46,7 +47,7 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer IDistributionListsService distributionListsService, IShiftsService shiftsService, IUnitsService unitsService, ICertificationService certificationService, ILogService logService, IInventoryService inventoryService, IEventAggregator eventAggregator, IAddressService addressService, IQueueService queueService, IEmailService emailService, - IDeleteRepository deleteRepository, IAuditLogsRepository auditLogsRepository) + IDeleteRepository deleteRepository, IAuditLogsRepository auditLogsRepository, IScheduledTasksService scheduledTasksService) { _authorizationService = authorizationService; _departmentsService = departmentsService; @@ -71,59 +72,99 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer _emailService = emailService; _deleteRepository = deleteRepository; _auditLogsRepository = auditLogsRepository; + _scheduledTasksService = scheduledTasksService; } - public async Task DeleteUserAsync(int departmentId, string authorizingUserId, string userIdToDelete) + public async Task DeleteUserAsync(int departmentId, string authorizingUserId, string userIdToDelete, CancellationToken cancellationToken = default(CancellationToken)) { if (!await _authorizationService.CanUserDeleteUserAsync(departmentId, authorizingUserId, userIdToDelete)) return DeleteUserResults.UnAuthroized; - var department = await _departmentsService.GetDepartmentByUserIdAsync(userIdToDelete); + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); - if (department.ManagingUserId == userIdToDelete) + if (department != null && department.ManagingUserId == userIdToDelete) return DeleteUserResults.UserIsManagingDepartmentAdmin; - var member = await _departmentsService.GetDepartmentMemberAsync(userIdToDelete, departmentId); - member.IsDeleted = true; - await _departmentsService.SaveDepartmentMemberAsync(member); - - //_certificationService.DeleteAllCertificationsForUser(userIdToDelete); - //_distributionListsService.RemoveUserFromAllLists(userIdToDelete); - //_personnelRolesService.RemoveUserFromAllRoles(userIdToDelete); - //_userStateService.DeleteStatesForUser(userIdToDelete); - //_workLogsService.ClearInvestigationByLogsForUser(userIdToDelete); - //_workLogsService.DeleteLogsForUser(userIdToDelete, department.ManagingUserId); - //_messageService.DeleteMessagesForUser(userIdToDelete); - ////_userProfileService.DeletProfileForUser(userIdToDelete); - //_pushUriService.DeletePushUrisForUser(userIdToDelete); - //_actionLogsService.DeleteActionLogsForUser(userIdToDelete); - //_callsService.DeleteDispatchesForUserAndRemapCalls(department.ManagingUserId, userIdToDelete); - //_departmentGroupsService.DeleteUserFromGroups(userIdToDelete); - //_usersService.DeleteUser(userIdToDelete); + var memberships = await _departmentsService.GetAllDepartmentsForUserAsync(userIdToDelete); + var hasOtherActiveMemberships = memberships != null && memberships.Any(x => x.DepartmentId != departmentId && !x.IsDeleted); + + if (!hasOtherActiveMemberships) + { + // This is the user's only department: deactivate the whole account (same flow as the + // self-service "Delete My Account") so we don't strand a login with no departments. + return await DeactivateUserAccountCoreAsync(userIdToDelete, departmentId, null, null, cancellationToken); + } + + // The user belongs to other departments: revoke this department's access and + // communications only. Their account, login and PII stay untouched so they can + // keep using their remaining departments. + await RevokeDepartmentAccessAsync(userIdToDelete, departmentId, authorizingUserId, cancellationToken); return DeleteUserResults.NoFailure; } - public async Task DeleteUserAccountAsync(int departmentId, string authorizingUserId, string userIdToDelete, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)) + public async Task RevokeDepartmentAccessAsync(string userId, int departmentId, string revokingUserId, CancellationToken cancellationToken = default(CancellationToken)) { - //if (!await _authorizationService.CanUserDeleteUserAsync(departmentId, authorizingUserId, userIdToDelete)) - // return DeleteUserResults.UnAuthroized; + // Strip everything that would keep the department reaching the user: roles, + // group memberships, distribution lists and their scheduled automations + // (status changes, staffing changes, report deliveries) for this department. + await _personnelRolesService.RemoveUserFromAllRolesAsync(userId, departmentId, cancellationToken); + await _departmentGroupsService.DeleteUserFromGroupsAsync(userId, departmentId, cancellationToken); + await _distributionListsService.RemoveUserFromAllListsInDepartmentAsync(userId, departmentId, cancellationToken); + await _scheduledTasksService.DeleteAllTasksForUserInDepartmentAsync(userId, departmentId, cancellationToken); + + // Soft-delete the membership last (this also writes the audit event and clears caches). + var member = await _departmentsService.DeleteUserAsync(departmentId, userId, revokingUserId, cancellationToken); + + return member != null && member.IsDeleted; + } + public async Task DeleteUserAccountAsync(int departmentId, string authorizingUserId, string userIdToDelete, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)) + { if (authorizingUserId != userIdToDelete) return DeleteUserResults.UnAuthroized; + return await DeactivateUserAccountCoreAsync(userIdToDelete, departmentId, ipAddress, userAgent, cancellationToken); + } + + private async Task DeactivateUserAccountCoreAsync(string userIdToDelete, int departmentId, string ipAddress, string userAgent, CancellationToken cancellationToken) + { var departments = await _departmentsService.GetAllDepartmentsForUserAsync(userIdToDelete); if (departments != null && departments.Any()) { + // Check every membership before mutating anything, and check the department each + // membership actually points at -- not just the user's primary department -- so we + // never leave a half-deleted account behind an early return. foreach (var dm in departments) { - var dep = await _departmentsService.GetDepartmentByUserIdAsync(userIdToDelete); + var dep = await _departmentsService.GetDepartmentByIdAsync(dm.DepartmentId); - if (dep.ManagingUserId == userIdToDelete) + if (dep != null && dep.ManagingUserId == userIdToDelete) return DeleteUserResults.UserIsManagingDepartmentAdmin; + } + // Strip roles and group memberships before touching the membership rows so a + // failure part-way through leaves the memberships intact and the whole + // operation retryable (mirrors the ordering in RevokeDepartmentAccessAsync). + foreach (var dm in departments) + { + await _personnelRolesService.RemoveUserFromAllRolesAsync(userIdToDelete, dm.DepartmentId, cancellationToken); + await _departmentGroupsService.DeleteUserFromGroupsAsync(userIdToDelete, dm.DepartmentId, cancellationToken); + } + } + // Kill every remaining automation and subscription for the user across all + // departments: distribution lists, scheduled status/staffing changes and + // scheduled report deliveries. Still runs before the membership rows are + // soft-deleted below so a failure here keeps the operation retryable. + await _distributionListsService.RemoveUserFromAllListsAsync(userIdToDelete, cancellationToken); + await _scheduledTasksService.DeleteAllTasksForUserAsync(userIdToDelete, cancellationToken); + + if (departments != null && departments.Any()) + { + foreach (var dm in departments) + { var auditEvent = new AuditEvent(); auditEvent.Before = dm.CloneJsonToString(); auditEvent.DepartmentId = dm.DepartmentId; @@ -168,6 +209,16 @@ public async Task DeleteUserAsync(int departmentId, string au userProfile.VoiceForCall = false; userProfile.VoiceCallHome = false; userProfile.VoiceCallMobile = false; + userProfile.MembershipEmail = null; + userProfile.EmailVerified = false; + userProfile.MobileNumberVerified = false; + userProfile.HomeNumberVerified = false; + userProfile.EmailVerificationCode = null; + userProfile.MobileVerificationCode = null; + userProfile.HomeVerificationCode = null; + userProfile.CalendarSyncToken = null; + userProfile.SecurityPin = null; + userProfile.SecurityPinEnabled = false; if (userProfile.HomeAddressId.HasValue) await _addressService.DeleteAddress(userProfile.HomeAddressId.Value, cancellationToken); diff --git a/Core/Resgrid.Services/DistributionListsService.cs b/Core/Resgrid.Services/DistributionListsService.cs index ea3224d89..1ad2ecbd3 100644 --- a/Core/Resgrid.Services/DistributionListsService.cs +++ b/Core/Resgrid.Services/DistributionListsService.cs @@ -107,5 +107,23 @@ public async Task> GetAllListMembersByListIdAsync(i return true; } + + public async Task RemoveUserFromAllListsInDepartmentAsync(string userId, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) + { + var members = await _distributionListMemberRepository.GetDistributionListMemberByUserIdAsync(userId); + + if (members == null || !members.Any()) + return true; + + var departmentLists = await GetDistributionListsByDepartmentIdAsync(departmentId); + var departmentListIds = departmentLists.Select(x => x.DistributionListId).ToHashSet(); + + foreach (var member in members.Where(x => departmentListIds.Contains(x.DistributionListId))) + { + await _distributionListMemberRepository.DeleteAsync(member, cancellationToken); + } + + return true; + } } } diff --git a/Core/Resgrid.Services/ScheduledTasksService.cs b/Core/Resgrid.Services/ScheduledTasksService.cs index bde9b8856..685d08011 100644 --- a/Core/Resgrid.Services/ScheduledTasksService.cs +++ b/Core/Resgrid.Services/ScheduledTasksService.cs @@ -232,6 +232,26 @@ public async Task> GetAllUpcomingOrRecurringReportDeliveryTa return new List(); } + public async Task DeleteAllTasksForUserAsync(string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await _scheduledTaskRepository.DeleteAllTasksForUserAsync(userId, cancellationToken); + InvalidateScheduledTasksCache(); + + return result; + } + + public async Task DeleteAllTasksForUserInDepartmentAsync(string userId, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await _scheduledTaskRepository.DeleteAllTasksForUserInDepartmentAsync(userId, departmentId, cancellationToken); + InvalidateScheduledTasksCache(); + + return result; + } + public async Task> GetUpcomingScheduledTasksAsync(DateTime currentTime, List tasks) { //Logging.LogTrace("ScheduledTasksService: Entering GetUpcomingScheduledTaks"); diff --git a/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs b/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs index 5bf607071..d30946510 100644 --- a/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs +++ b/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs @@ -49,6 +49,11 @@ public T Retrieve(string cacheKey, Func fallbackFunction, TimeSpan expirat } catch (TimeoutException) { } + catch (RedisConnectionException ex) + { + // Transient connection drop (idle reset, failover); fallback below handles it. + Logging.LogError(ex); + } catch (Exception ex) { Logging.LogException(ex); @@ -66,6 +71,10 @@ public T Retrieve(string cacheKey, Func fallbackFunction, TimeSpan expirat } catch (TimeoutException) { } + catch (RedisConnectionException ex) + { + Logging.LogError(ex); + } catch (Exception ex) { Logging.LogException(ex); @@ -86,6 +95,12 @@ public void Remove(string cacheKey) cache.KeyDelete(SetCacheKeyForEnv(cacheKey)); } } + catch (TimeoutException) + { } + catch (RedisConnectionException ex) + { + Logging.LogError(ex); + } catch (Exception ex) { Logging.LogException(ex); @@ -125,6 +140,11 @@ public async Task RetrieveAsync(string cacheKey, Func> fallbackFun } catch (TimeoutException) { } + catch (RedisConnectionException ex) + { + // Transient connection drop (idle reset, failover); fallback below handles it. + Logging.LogError(ex); + } catch (Exception ex) { Logging.LogException(ex); @@ -142,6 +162,10 @@ public async Task RetrieveAsync(string cacheKey, Func> fallbackFun } catch (TimeoutException) { } + catch (RedisConnectionException ex) + { + Logging.LogError(ex); + } catch (Exception ex) { Logging.LogException(ex); @@ -168,6 +192,10 @@ public async Task SetStringAsync(string cacheKey, string value, TimeSpan e } catch (TimeoutException) { } + catch (RedisConnectionException ex) + { + Logging.LogError(ex); + } catch (Exception ex) { Logging.LogException(ex); @@ -195,6 +223,12 @@ public async Task GetStringAsync(string cacheKey) if (cacheValue.HasValue) return cacheValue.ToString(); } + catch (TimeoutException) + { } + catch (RedisConnectionException ex) + { + Logging.LogError(ex); + } catch (Exception ex) { Logging.LogException(ex); @@ -227,6 +261,12 @@ public async Task RemoveAsync(string cacheKey) return true; } } + catch (TimeoutException) + { } + catch (RedisConnectionException ex) + { + Logging.LogError(ex); + } catch (Exception ex) { Logging.LogException(ex); @@ -263,6 +303,12 @@ public async Task IncrementAsync(string cacheKey, TimeSpan expiration) return (long)result; } } + catch (TimeoutException) + { } + catch (RedisConnectionException ex) + { + Logging.LogError(ex); + } catch (Exception ex) { Logging.LogException(ex); @@ -311,6 +357,11 @@ private void EstablishRedisConnection() options.SyncTimeout = 1000; options.AsyncTimeout = 1000; + // Ping every 30s so idle connections aren't reset by intermediate + // idle timeouts (conntrack/LB/redis server timeout) before the + // default 60s keep-alive fires. + options.KeepAlive = 30; + _connection = ConnectionMultiplexer.Connect(options); } catch (Exception ex) diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs index 36ec0a702..0271d3265 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs @@ -47,6 +47,12 @@ SELECT UnitId FROM [dbo].[Units] WHERE DepartmentId = @DepartmentId + -- Child rows of data the cursors below delete piecemeal; remove while parents still exist + DELETE FROM [dbo].[ScheduledTaskLogs] WHERE ScheduledTaskId IN (SELECT ScheduledTaskId FROM [dbo].[ScheduledTasks] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[LogAttachments] WHERE LogId IN (SELECT LogId FROM [dbo].[Logs] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[LogUnits] WHERE LogId IN (SELECT LogId FROM [dbo].[Logs] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[LogUsers] WHERE LogId IN (SELECT LogId FROM [dbo].[Logs] WHERE DepartmentId = @DepartmentId) + OPEN db_cursor FETCH NEXT FROM db_cursor INTO @UserId @@ -77,6 +83,8 @@ DELETE FROM [dbo].[MessageRecipients] WHERE MessageId IN (SELECT MessageId FROM IF (SELECT COUNT(*) FROM DepartmentMembers WHERE UserId = @UserId) = 1 BEGIN -- This user is only a member of one department so clear their account out as well + DELETE FROM [dbo].[ChatbotUserIdentities] WHERE UserId = @UserId + DELETE FROM [dbo].[ChatbotLinkingCodes] WHERE UserId = @UserId DELETE FROM [dbo].[UserProfiles] WHERE UserId = @UserId DELETE FROM [dbo].[AspNetUserClaims] WHERE UserId = @UserId DELETE FROM [dbo].[AspNetUserLogins] WHERE UserId = @UserId @@ -97,6 +105,7 @@ FETCH NEXT FROM unit_cursor INTO @UnitId -- Clear all the unit out in the department WHILE @@FETCH_STATUS = 0 BEGIN + DELETE FROM [dbo].[UnitLocations] WHERE UnitId = @UnitId DELETE FROM [dbo].[UnitLogs] WHERE UnitId = @UnitId DELETE FROM [dbo].[UnitActiveRoles] WHERE UnitId = @UnitId DELETE FROM [dbo].[UnitRoles] WHERE UnitId = @UnitId @@ -112,6 +121,126 @@ CLOSE unit_cursor DEALLOCATE unit_cursor -- Delete all the department level data + -- Call child data (parents deleted further down) + DELETE FROM [dbo].[CallAttachments] WHERE CallId IN (SELECT CallId FROM [dbo].[Calls] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[CallNotes] WHERE CallId IN (SELECT CallId FROM [dbo].[Calls] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[CallDispatches] WHERE CallId IN (SELECT CallId FROM [dbo].[Calls] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[CallDispatchGroups] WHERE CallId IN (SELECT CallId FROM [dbo].[Calls] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[CallDispatchRoles] WHERE CallId IN (SELECT CallId FROM [dbo].[Calls] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[CallDispatchUnits] WHERE CallId IN (SELECT CallId FROM [dbo].[Calls] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[CallUnits] WHERE CallId IN (SELECT CallId FROM [dbo].[Calls] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[CallProtocols] WHERE CallId IN (SELECT CallId FROM [dbo].[Calls] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[CallLogs] WHERE DepartmentId = @DepartmentId + + -- Command definitions reference CallTypes (CallTypeId), so their tree must go first + DELETE FROM [dbo].[CommandDefinitionRolePersonnelRoles] WHERE CommandDefinitionRoleId IN (SELECT CommandDefinitionRoleId FROM [dbo].[CommandDefinitionRoles] WHERE CommandDefinitionId IN (SELECT CommandDefinitionId FROM [dbo].[CommandDefinitions] WHERE DepartmentId = @DepartmentId)) + DELETE FROM [dbo].[CommandDefinitionRoleUnitTypes] WHERE CommandDefinitionRoleId IN (SELECT CommandDefinitionRoleId FROM [dbo].[CommandDefinitionRoles] WHERE CommandDefinitionId IN (SELECT CommandDefinitionId FROM [dbo].[CommandDefinitions] WHERE DepartmentId = @DepartmentId)) + DELETE FROM [dbo].[CommandDefinitionRoles] WHERE CommandDefinitionId IN (SELECT CommandDefinitionId FROM [dbo].[CommandDefinitions] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[CommandDefinitions] WHERE DepartmentId = @DepartmentId + + DELETE FROM [dbo].[CallTypes] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[CallVideoFeeds] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[DepartmentCallEmails] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[DepartmentCallPriorities] WHERE DepartmentId = @DepartmentId + + -- Shift tree (Shifts row deleted further down) + DELETE FROM [dbo].[ShiftSignupTradeUserShifts] WHERE ShiftSignupId IN (SELECT ShiftSignupId FROM [dbo].[ShiftSignups] WHERE ShiftId IN (SELECT ShiftId FROM [dbo].[Shifts] WHERE DepartmentId = @DepartmentId)) + DELETE FROM [dbo].[ShiftSignupTradeUsers] WHERE ShiftSignupTradeId IN (SELECT ShiftSignupTradeId FROM [dbo].[ShiftSignupTrades] WHERE SourceShiftSignupId IN (SELECT ShiftSignupId FROM [dbo].[ShiftSignups] WHERE ShiftId IN (SELECT ShiftId FROM [dbo].[Shifts] WHERE DepartmentId = @DepartmentId)) OR TargetShiftSignupId IN (SELECT ShiftSignupId FROM [dbo].[ShiftSignups] WHERE ShiftId IN (SELECT ShiftId FROM [dbo].[Shifts] WHERE DepartmentId = @DepartmentId))) + DELETE FROM [dbo].[ShiftSignupTrades] WHERE SourceShiftSignupId IN (SELECT ShiftSignupId FROM [dbo].[ShiftSignups] WHERE ShiftId IN (SELECT ShiftId FROM [dbo].[Shifts] WHERE DepartmentId = @DepartmentId)) OR TargetShiftSignupId IN (SELECT ShiftSignupId FROM [dbo].[ShiftSignups] WHERE ShiftId IN (SELECT ShiftId FROM [dbo].[Shifts] WHERE DepartmentId = @DepartmentId)) + DELETE FROM [dbo].[ShiftSignups] WHERE ShiftId IN (SELECT ShiftId FROM [dbo].[Shifts] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[ShiftGroupAssignments] WHERE ShiftGroupId IN (SELECT ShiftGroupId FROM [dbo].[ShiftGroups] WHERE ShiftId IN (SELECT ShiftId FROM [dbo].[Shifts] WHERE DepartmentId = @DepartmentId)) + DELETE FROM [dbo].[ShiftGroupRoles] WHERE ShiftGroupId IN (SELECT ShiftGroupId FROM [dbo].[ShiftGroups] WHERE ShiftId IN (SELECT ShiftId FROM [dbo].[Shifts] WHERE DepartmentId = @DepartmentId)) + DELETE FROM [dbo].[ShiftGroups] WHERE ShiftId IN (SELECT ShiftId FROM [dbo].[Shifts] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[ShiftDays] WHERE ShiftId IN (SELECT ShiftId FROM [dbo].[Shifts] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[ShiftAdmins] WHERE ShiftId IN (SELECT ShiftId FROM [dbo].[Shifts] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[ShiftPersons] WHERE ShiftId IN (SELECT ShiftId FROM [dbo].[Shifts] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[ShiftStaffingPersons] WHERE ShiftStaffingId IN (SELECT ShiftStaffingId FROM [dbo].[ShiftStaffings] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[ShiftStaffings] WHERE DepartmentId = @DepartmentId + + -- Training tree (Trainings row deleted further down) + DELETE FROM [dbo].[TrainingQuestionAnswers] WHERE TrainingQuestionId IN (SELECT TrainingQuestionId FROM [dbo].[TrainingQuestions] WHERE TrainingId IN (SELECT TrainingId FROM [dbo].[Trainings] WHERE DepartmentId = @DepartmentId)) + DELETE FROM [dbo].[TrainingQuestions] WHERE TrainingId IN (SELECT TrainingId FROM [dbo].[Trainings] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[TrainingAttachments] WHERE TrainingId IN (SELECT TrainingId FROM [dbo].[Trainings] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[TrainingUsers] WHERE TrainingId IN (SELECT TrainingId FROM [dbo].[Trainings] WHERE DepartmentId = @DepartmentId) + + -- Dispatch protocol tree (DispatchProtocols row deleted further down) + DELETE FROM [dbo].[DispatchProtocolQuestionAnswers] WHERE DispatchProtocolQuestionId IN (SELECT DispatchProtocolQuestionId FROM [dbo].[DispatchProtocolQuestions] WHERE DispatchProtocolId IN (SELECT DispatchProtocolId FROM [dbo].[DispatchProtocols] WHERE DepartmentId = @DepartmentId)) + DELETE FROM [dbo].[DispatchProtocolQuestions] WHERE DispatchProtocolId IN (SELECT DispatchProtocolId FROM [dbo].[DispatchProtocols] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[DispatchProtocolAttachments] WHERE DispatchProtocolId IN (SELECT DispatchProtocolId FROM [dbo].[DispatchProtocols] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[DispatchProtocolTriggers] WHERE DispatchProtocolId IN (SELECT DispatchProtocolId FROM [dbo].[DispatchProtocols] WHERE DepartmentId = @DepartmentId) + + -- Calendar + DELETE FROM [dbo].[CalendarItemAttendees] WHERE CalendarItemId IN (SELECT CalendarItemId FROM [dbo].[CalendarItems] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[CalendarItems] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[CalendarItemTypes] WHERE DepartmentId = @DepartmentId + + -- Custom statuses + DELETE FROM [dbo].[CustomStateDetails] WHERE CustomStateId IN (SELECT CustomStateId FROM [dbo].[CustomStates] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[CustomStates] WHERE DepartmentId = @DepartmentId + + -- Mapping / POIs + DELETE FROM [dbo].[Pois] WHERE PoiTypeId IN (SELECT PoiTypeId FROM [dbo].[POITypes] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[POITypes] WHERE DepartmentId = @DepartmentId + + -- Resource orders (ResourceOrders row deleted further down) + DELETE FROM [dbo].[ResourceOrderFillUnits] WHERE ResourceOrderFillId IN (SELECT ResourceOrderFillId FROM [dbo].[ResourceOrderFills] WHERE DepartmentId = @DepartmentId OR ResourceOrderId IN (SELECT ResourceOrderId FROM [dbo].[ResourceOrders] WHERE DepartmentId = @DepartmentId)) + DELETE FROM [dbo].[ResourceOrderFills] WHERE DepartmentId = @DepartmentId OR ResourceOrderId IN (SELECT ResourceOrderId FROM [dbo].[ResourceOrders] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[ResourceOrderItems] WHERE ResourceOrderId IN (SELECT ResourceOrderId FROM [dbo].[ResourceOrders] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[ResourceOrderSettings] WHERE DepartmentId = @DepartmentId + + -- Department public profile tree + DELETE FROM [dbo].[DepartmentProfileArticles] WHERE DepartmentProfileId IN (SELECT DepartmentProfileId FROM [dbo].[DepartmentProfiles] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[DepartmentProfileInvites] WHERE DepartmentProfileId IN (SELECT DepartmentProfileId FROM [dbo].[DepartmentProfiles] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[DepartmentProfileMessages] WHERE DepartmentProfileId IN (SELECT DepartmentProfileId FROM [dbo].[DepartmentProfiles] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[DepartmentProfileUserFollows] WHERE DepartmentProfileId IN (SELECT DepartmentProfileId FROM [dbo].[DepartmentProfiles] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[DepartmentProfiles] WHERE DepartmentId = @DepartmentId + + -- User-defined fields + DELETE FROM [dbo].[UdfFieldValues] WHERE UdfDefinitionId IN (SELECT UdfDefinitionId FROM [dbo].[UdfDefinitions] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[UdfFields] WHERE UdfDefinitionId IN (SELECT UdfDefinitionId FROM [dbo].[UdfDefinitions] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[UdfDefinitions] WHERE DepartmentId = @DepartmentId + + -- Weather alerts + DELETE FROM [dbo].[WeatherAlertZones] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[WeatherAlerts] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[WeatherAlertSources] WHERE DepartmentId = @DepartmentId + + -- Communication tests + DELETE FROM [dbo].[CommunicationTestResults] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[CommunicationTestRuns] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[CommunicationTests] WHERE DepartmentId = @DepartmentId + + -- Chatbot + DELETE FROM [dbo].[ChatbotMessageLog] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[ChatbotDepartmentConfigs] WHERE DepartmentId = @DepartmentId + + -- Remaining department-scoped tables + DELETE FROM [dbo].[AuditLogs] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[Automations] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[DepartmentCertificationTypes] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[DepartmentFiles] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[Files] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[DepartmentNotifications] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[DepartmentSecurityPolicies] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[DepartmentSsoConfigs] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[DocumentCategories] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[NoteCategories] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[FeatureFlagOverrides] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[FeatureFlagUsages] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[GdprDataExportRequests] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[NotificationAlerts] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[Permissions] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[Ranks] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[ActiveDepartments] WHERE ActiveDepartmentId = @DepartmentId + + -- Catch-alls for rows the per-user cursor missed (users removed from the + -- department before deletion, or rows with no surviving member) + DELETE FROM [dbo].[ScheduledTasks] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[UserStates] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[PersonnelCertifications] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[PersonnelRoleUsers] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[PushUris] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[Invites] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[Payments] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[ActionLogs] WHERE DepartmentId = @DepartmentId @@ -131,7 +260,6 @@ DELETE FROM [dbo].[DepartmentGroupMembers] WHERE DepartmentGroupId IN (SELECT De DELETE FROM [dbo].[Notes] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[Payments] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[PersonnelRoles] WHERE DepartmentId = @DepartmentId - DELETE FROM [dbo].[CommandDefinitions] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[UnitTypes] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[DispatchProtocols] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[Forms] WHERE DepartmentId = @DepartmentId @@ -143,6 +271,7 @@ DELETE FROM [dbo].[DepartmentGroupMembers] WHERE DepartmentGroupId IN (SELECT De DELETE FROM [dbo].[DepartmentVoiceChannels] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[DepartmentVoices] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[CallQuickTemplates] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[DepartmentCallPruning] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[Departments] WHERE DepartmentId = @DepartmentId -- Delete the managing member's user @@ -167,6 +296,8 @@ DELETE FROM [dbo].[MessageRecipients] WHERE MessageId IN (SELECT MessageId FROM DELETE FROM [dbo].[PushUris] WHERE UserId = @ManagingUserId DELETE FROM [dbo].[UnitStateRoles] WHERE UserId = @ManagingUserId DELETE FROM [dbo].[CallDispatches] WHERE UserId = @ManagingUserId + DELETE FROM [dbo].[ChatbotUserIdentities] WHERE UserId = @ManagingUserId + DELETE FROM [dbo].[ChatbotLinkingCodes] WHERE UserId = @ManagingUserId DELETE FROM [dbo].[AspNetUserClaims] WHERE UserId = @ManagingUserId DELETE FROM [dbo].[AspNetUserLogins] WHERE UserId = @ManagingUserId DELETE FROM [dbo].[AspNetUserRoles] WHERE UserId = @ManagingUserId diff --git a/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs index e7ef1e883..f79bb9679 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs @@ -465,45 +465,101 @@ public async Task ClearOutUserLoginAsync(string userId) { if (DataConfig.DatabaseType == DatabaseTypes.Postgres) { - using (IDbConnection db = new NpgsqlConnection(DataConfig.CoreConnectionString)) - { - var deleteId = Guid.NewGuid().ToString(); - var maskedEmail = deleteId + "@resgrid.del"; - // Full de-provisioning: mask the normalized columns too (so ASP.NET Identity's normalized - // lookups can't find the row), null the password hash, rotate the security stamp, and lock - // the account so a deleted user can no longer authenticate. - var result = await db.ExecuteAsync(@"UPDATE public.aspnetusers - SET username = @deleteId, - normalizedusername = @normalizedDeleteId, - email = @maskedEmail, - normalizedemail = @normalizedMaskedEmail, - passwordhash = NULL, - securitystamp = @securityStamp, - emailconfirmed = false, - lockoutenabled = true, - lockoutend = @lockoutEnd - WHERE id = @userId", - new { userId = userId, deleteId = deleteId, normalizedDeleteId = deleteId.ToUpperInvariant(), maskedEmail = maskedEmail, normalizedMaskedEmail = maskedEmail.ToUpperInvariant(), securityStamp = Guid.NewGuid().ToString(), lockoutEnd = new DateTimeOffset(9999, 12, 31, 23, 59, 59, TimeSpan.Zero) }); + using (var db = new NpgsqlConnection(DataConfig.CoreConnectionString)) + { + await db.OpenAsync(); + + // The whole de-provision is atomic: either the account is fully fuzzed and all + // re-entry vectors are removed, or nothing changes and the operation can be retried. + using (var transaction = db.BeginTransaction()) + { + try + { + var deleteId = Guid.NewGuid().ToString(); + var maskedEmail = deleteId + "@resgrid.del"; + // Full de-provisioning: mask the normalized columns too (so ASP.NET Identity's normalized + // lookups can't find the row), null the password hash, rotate the security stamp, and lock + // the account so a deleted user can no longer authenticate. + var result = await db.ExecuteAsync(@"UPDATE public.aspnetusers + SET username = @deleteId, + normalizedusername = @normalizedDeleteId, + email = @maskedEmail, + normalizedemail = @normalizedMaskedEmail, + passwordhash = NULL, + securitystamp = @securityStamp, + emailconfirmed = false, + phonenumber = NULL, + phonenumberconfirmed = false, + twofactorenabled = false, + lockoutenabled = true, + lockoutend = @lockoutEnd + WHERE id = @userId", + new { userId = userId, deleteId = deleteId, normalizedDeleteId = deleteId.ToUpperInvariant(), maskedEmail = maskedEmail, normalizedMaskedEmail = maskedEmail.ToUpperInvariant(), securityStamp = Guid.NewGuid().ToString(), lockoutEnd = new DateTimeOffset(9999, 12, 31, 23, 59, 59, TimeSpan.Zero) }, transaction); + + // External login mappings, recovery secrets, device push registrations and chatbot + // platform links can all be used to reach or re-enter the account -- remove them too. + await db.ExecuteAsync(@"DELETE FROM public.aspnetuserlogins WHERE userid = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"UPDATE public.aspnetusersext SET securityquestion = NULL, securityanswer = NULL, securityanswersalt = NULL WHERE userid = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"DELETE FROM public.pushuris WHERE userid = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"DELETE FROM public.chatbotuseridentities WHERE userid = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"DELETE FROM public.chatbotlinkingcodes WHERE userid = @userId", new { userId = userId }, transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } } } else { - using (IDbConnection db = new SqlConnection(DataConfig.CoreConnectionString)) - { - var deleteId = Guid.NewGuid().ToString(); - var maskedEmail = deleteId + "@resgrid.del"; - var result = await db.ExecuteAsync(@"UPDATE AspNetUsers - SET UserName = @deleteId, - NormalizedUserName = @normalizedDeleteId, - Email = @maskedEmail, - NormalizedEmail = @normalizedMaskedEmail, - PasswordHash = NULL, - SecurityStamp = @securityStamp, - EmailConfirmed = 0, - LockoutEnabled = 1, - LockoutEnd = @lockoutEnd - WHERE Id = @userId", - new { userId = userId, deleteId = deleteId, normalizedDeleteId = deleteId.ToUpperInvariant(), maskedEmail = maskedEmail, normalizedMaskedEmail = maskedEmail.ToUpperInvariant(), securityStamp = Guid.NewGuid().ToString(), lockoutEnd = new DateTimeOffset(9999, 12, 31, 23, 59, 59, TimeSpan.Zero) }); + using (var db = new SqlConnection(DataConfig.CoreConnectionString)) + { + await db.OpenAsync(); + + // The whole de-provision is atomic: either the account is fully fuzzed and all + // re-entry vectors are removed, or nothing changes and the operation can be retried. + using (var transaction = db.BeginTransaction()) + { + try + { + var deleteId = Guid.NewGuid().ToString(); + var maskedEmail = deleteId + "@resgrid.del"; + var result = await db.ExecuteAsync(@"UPDATE AspNetUsers + SET UserName = @deleteId, + NormalizedUserName = @normalizedDeleteId, + Email = @maskedEmail, + NormalizedEmail = @normalizedMaskedEmail, + PasswordHash = NULL, + SecurityStamp = @securityStamp, + EmailConfirmed = 0, + PhoneNumber = NULL, + PhoneNumberConfirmed = 0, + TwoFactorEnabled = 0, + LockoutEnabled = 1, + LockoutEnd = @lockoutEnd + WHERE Id = @userId", + new { userId = userId, deleteId = deleteId, normalizedDeleteId = deleteId.ToUpperInvariant(), maskedEmail = maskedEmail, normalizedMaskedEmail = maskedEmail.ToUpperInvariant(), securityStamp = Guid.NewGuid().ToString(), lockoutEnd = new DateTimeOffset(9999, 12, 31, 23, 59, 59, TimeSpan.Zero) }, transaction); + + // External login mappings, recovery secrets, device push registrations and chatbot + // platform links can all be used to reach or re-enter the account -- remove them too. + await db.ExecuteAsync(@"DELETE FROM AspNetUserLogins WHERE UserId = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"UPDATE AspNetUsersExt SET SecurityQuestion = NULL, SecurityAnswer = NULL, SecurityAnswerSalt = NULL WHERE UserId = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"DELETE FROM PushUris WHERE UserId = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"DELETE FROM ChatbotUserIdentities WHERE UserId = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"DELETE FROM ChatbotLinkingCodes WHERE UserId = @userId", new { userId = userId }, transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } } } @@ -518,6 +574,8 @@ public async Task CleanUpOIDCTokensByUserAsync(string userId) { var result = await db.ExecuteAsync(@"DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" = @userId", new { userId = userId }); + await db.ExecuteAsync(@"DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" = @userId", + new { userId = userId }); } } else @@ -527,6 +585,9 @@ public async Task CleanUpOIDCTokensByUserAsync(string userId) var result = await db.ExecuteAsync(@"DELETE FROM OpenIddictTokens WHERE Subject = @userId", new { userId = userId }); + await db.ExecuteAsync(@"DELETE FROM OpenIddictAuthorizations + WHERE Subject = @userId", + new { userId = userId }); } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/ScheduledTasksRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ScheduledTasksRepository.cs index f22d2fc7c..67d456362 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ScheduledTasksRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ScheduledTasksRepository.cs @@ -9,6 +9,7 @@ using Dapper; using System; using System.Data.Common; +using System.Threading; using System.Threading.Tasks; using Resgrid.Framework; using Resgrid.Model.Repositories.Connection; @@ -63,7 +64,8 @@ FROM scheduledtasks st FROM scheduledtasks st INNER JOIN departmentmembers dm ON dm.userid = st.userid INNER JOIN departments d ON d.departmentid = dm.departmentid - WHERE st.departmentid = 0 AND st.active = true AND st.tasktype = any (@types)", new { types = types }); + WHERE st.departmentid = 0 AND st.active = true AND st.tasktype = any (@types) + AND dm.isdeleted = false AND (dm.isdisabled IS NULL OR dm.isdisabled = false)", new { types = types }); return knownDepartments.Concat(unknownDepartments); } @@ -81,7 +83,8 @@ FROM ScheduledTasks st FROM ScheduledTasks st INNER JOIN DepartmentMembers dm ON dm.UserId = st.UserId INNER JOIN Departments d ON d.DepartmentId = dm.DepartmentId - WHERE st.DepartmentId = 0 AND st.Active = 1 AND st.TaskType IN @types", new { types = types }); + WHERE st.DepartmentId = 0 AND st.Active = 1 AND st.TaskType IN @types + AND dm.IsDeleted = 0 AND (dm.IsDisabled IS NULL OR dm.IsDisabled = 0)", new { types = types }); return knownDepartments.Concat(unknownDepartments); } @@ -133,6 +136,120 @@ public async Task> GetAllUpcomingOrRecurringReportDel } + public async Task DeleteAllTasksForUserAsync(string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (Config.DataConfig.DatabaseType == DatabaseTypes.Postgres) + { + using (var db = new NpgsqlConnection(DataConfig.CoreConnectionString)) + { + await db.OpenAsync(cancellationToken); + + // Logs and their tasks go together: never leave tasks whose logs are already gone. + using (var transaction = db.BeginTransaction()) + { + try + { + await db.ExecuteAsync(new Dapper.CommandDefinition(@"DELETE FROM scheduledtasklogs WHERE scheduledtaskid IN (SELECT scheduledtaskid FROM scheduledtasks WHERE userid = @userId)", new { userId = userId }, transaction: transaction, cancellationToken: cancellationToken)); + await db.ExecuteAsync(new Dapper.CommandDefinition(@"DELETE FROM scheduledtasks WHERE userid = @userId", new { userId = userId }, transaction: transaction, cancellationToken: cancellationToken)); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + else + { + using (var db = new SqlConnection(DataConfig.CoreConnectionString)) + { + await db.OpenAsync(cancellationToken); + + using (var transaction = db.BeginTransaction()) + { + try + { + await db.ExecuteAsync(new Dapper.CommandDefinition(@"DELETE FROM ScheduledTaskLogs WHERE ScheduledTaskId IN (SELECT ScheduledTaskId FROM ScheduledTasks WHERE UserId = @userId)", new { userId = userId }, transaction: transaction, cancellationToken: cancellationToken)); + await db.ExecuteAsync(new Dapper.CommandDefinition(@"DELETE FROM ScheduledTasks WHERE UserId = @userId", new { userId = userId }, transaction: transaction, cancellationToken: cancellationToken)); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + + return true; + } + + public async Task DeleteAllTasksForUserInDepartmentAsync(string userId, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) + { + // Fail closed: a non-positive id would match the legacy DepartmentId = 0 rows, + // which belong to the user across departments and must survive a single-department revoke. + if (departmentId <= 0) + { + Logging.LogWarning($"DeleteAllTasksForUserInDepartmentAsync called with non-positive departmentId {departmentId} for user {userId}; skipping delete."); + return false; + } + + if (Config.DataConfig.DatabaseType == DatabaseTypes.Postgres) + { + using (var db = new NpgsqlConnection(DataConfig.CoreConnectionString)) + { + await db.OpenAsync(cancellationToken); + + // Logs and their tasks go together: never leave tasks whose logs are already gone. + using (var transaction = db.BeginTransaction()) + { + try + { + await db.ExecuteAsync(new Dapper.CommandDefinition(@"DELETE FROM scheduledtasklogs WHERE scheduledtaskid IN (SELECT scheduledtaskid FROM scheduledtasks WHERE userid = @userId AND departmentid = @departmentId)", new { userId = userId, departmentId = departmentId }, transaction: transaction, cancellationToken: cancellationToken)); + await db.ExecuteAsync(new Dapper.CommandDefinition(@"DELETE FROM scheduledtasks WHERE userid = @userId AND departmentid = @departmentId", new { userId = userId, departmentId = departmentId }, transaction: transaction, cancellationToken: cancellationToken)); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + else + { + using (var db = new SqlConnection(DataConfig.CoreConnectionString)) + { + await db.OpenAsync(cancellationToken); + + using (var transaction = db.BeginTransaction()) + { + try + { + await db.ExecuteAsync(new Dapper.CommandDefinition(@"DELETE FROM ScheduledTaskLogs WHERE ScheduledTaskId IN (SELECT ScheduledTaskId FROM ScheduledTasks WHERE UserId = @userId AND DepartmentId = @departmentId)", new { userId = userId, departmentId = departmentId }, transaction: transaction, cancellationToken: cancellationToken)); + await db.ExecuteAsync(new Dapper.CommandDefinition(@"DELETE FROM ScheduledTasks WHERE UserId = @userId AND DepartmentId = @departmentId", new { userId = userId, departmentId = departmentId }, transaction: transaction, cancellationToken: cancellationToken)); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + + return true; + } + public List GetDepartmentsForSelectedTasks(List scheduleTasksIds) { //using (IDbConnection db = new SqlConnection(connectionString)) diff --git a/Tests/Resgrid.Tests/Config/CorsHelperTests.cs b/Tests/Resgrid.Tests/Config/CorsHelperTests.cs index 6a2ef61a1..8fa60dd04 100644 --- a/Tests/Resgrid.Tests/Config/CorsHelperTests.cs +++ b/Tests/Resgrid.Tests/Config/CorsHelperTests.cs @@ -147,6 +147,32 @@ public void should_match_bare_host_entries_on_any_scheme_and_port() CorsHelper.IsAllowedOrigin("https://sub.mydispatch.example.com").Should().BeFalse(); } + [Test] + public void should_match_wildcard_host_entries_for_apex_and_subdomains() + { + ApiConfig.CorsAllowedOrigins = "*.resgrid.com, *.resgrid.io"; + + CorsHelper.IsAllowedOrigin("https://resgrid.com").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://dispatch.resgrid.com").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("http://unit.resgrid.com:3000").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://app.resgrid.io").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://evilresgrid.com").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("https://resgrid.com.evil.com").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("https://resgrid.org").Should().BeFalse(); + } + + [Test] + public void should_match_electron_custom_scheme_origin_verbatim() + { + ApiConfig.CorsAllowedOrigins = "app://., http://localhost:8081"; + + CorsHelper.IsAllowedOrigin("app://.").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("APP://.").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("app://evil").Should().BeFalse(); + // The sandboxed-iframe/file:// "null" origin must stay blocked unless listed. + CorsHelper.IsAllowedOrigin("null").Should().BeFalse(); + } + [Test] public void should_allow_everything_with_a_wildcard_entry() { diff --git a/Tests/Resgrid.Tests/Mocks/MockScheduledTasksRepository.cs b/Tests/Resgrid.Tests/Mocks/MockScheduledTasksRepository.cs index 5ad452e86..d8cf80041 100644 --- a/Tests/Resgrid.Tests/Mocks/MockScheduledTasksRepository.cs +++ b/Tests/Resgrid.Tests/Mocks/MockScheduledTasksRepository.cs @@ -72,6 +72,23 @@ public Task> GetAllActiveTasksForTypesAsync(List public Task> GetAllUpcomingOrRecurringReportDeliveryTasksAsync() => Task.FromResult>(new List()); + + public Task DeleteAllTasksForUserAsync(string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + _tasks.RemoveAll(t => t.UserId == userId); + return Task.FromResult(true); + } + + public Task DeleteAllTasksForUserInDepartmentAsync(string userId, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) + { + // Mirror the real repository: fail closed on non-positive ids so legacy + // DepartmentId = 0 rows survive a single-department revoke. + if (departmentId <= 0) + return Task.FromResult(false); + + _tasks.RemoveAll(t => t.UserId == userId && t.DepartmentId == departmentId); + return Task.FromResult(true); + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index 63d854bcc..5a10dafb1 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -95,17 +95,21 @@ public ChatController( /// /// Optional unit the user is actively operating as /// Include archived channels — the point-in-time record of closed incidents and calls. Off by default so the everyday list stays current. + /// Optional call to narrow the result to — only channels attached to this call are returned, so callers like an incident view don't pull the whole department list. /// Array of ChatChannelResultData objects for the channels the user can access [HttpGet("GetChannels")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetChannels(int? activeUnitId = null, bool includeArchived = false) + public async Task> GetChannels(int? activeUnitId = null, bool includeArchived = false, int? callId = null) { if (!await ChatEnabledAsync()) return NotFound(); var result = new GetChatChannelsResult(); var channels = await _chatChannelService.GetChannelsForUserAsync(DepartmentId, UserId, activeUnitId, includeArchived); + + if (callId.HasValue && channels != null) + channels = channels.Where(x => x.CallId == callId.Value).ToList(); var memberRows = await _chatChannelService.GetActiveMembershipsForUserAsync(DepartmentId, UserId); var membersByChannel = new Dictionary(); diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index e5b478409..24fc4bbf7 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -485,12 +485,13 @@ Realtime chat system interaction (channels, messages, reactions, attachments and presence) - + Returns all the chat channels the current user can access, with per-channel unread counts. Optional unit the user is actively operating as Include archived channels — the point-in-time record of closed incidents and calls. Off by default so the everyday list stays current. + Optional call to narrow the result to — only channels attached to this call are returned, so callers like an incident view don't pull the whole department list. Array of ChatChannelResultData objects for the channels the user can access diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index 7dfa1e3ec..6c6327886 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -691,8 +691,7 @@ public async Task DeletePerson(DeletePersonModel model, Cancellat { if (model.AreYouSure) { - var member = await _departmentsService.DeleteUserAsync(DepartmentId, model.UserId, UserId, cancellationToken); - //var result = await _deleteService.DeleteUser(DepartmentId, UserId, model.UserId); + var result = await _deleteService.DeleteUserAsync(DepartmentId, UserId, model.UserId, cancellationToken); _userProfileService.ClearUserProfileFromCache(model.UserId); _userProfileService.ClearAllUserProfilesFromCache(model.Department.DepartmentId); @@ -703,12 +702,15 @@ public async Task DeletePerson(DeletePersonModel model, Cancellat _eventAggregator.SendMessage(new DepartmentSettingsChangedEvent() { DepartmentId = DepartmentId }); - if (member != null && member.IsDeleted) + if (result == DeleteUserResults.NoFailure) { return RedirectToAction("Index", "Personnel", new { area = "User" }); } - ModelState.AddModelError("", "Error while trying to delete this person, please try again latter."); + if (result == DeleteUserResults.UserIsManagingDepartmentAdmin) + ModelState.AddModelError("", "Cannot delete the Managing User"); + else + ModelState.AddModelError("", "Error while trying to delete this person, please try again later."); } else { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs index 24dec6871..5befe8672 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs @@ -50,13 +50,14 @@ public class ProfileController : SecureBaseController private readonly SignInManager _signInManager; private readonly IDepartmentSsoService _departmentSsoService; private readonly IStringLocalizer _secLocalizer; + private readonly IDeleteService _deleteService; public ProfileController(IDepartmentsService departmentsService, IUsersService usersService, Model.Services.IAuthorizationService authorizationService, IUserProfileService userProfileService, IScheduledTasksService scheduledTasksService, ICertificationService certificationService, ICustomStateService customStateService, IImageService imageService, IOptions appOptionsAccessor, IEmailService emailService, UserManager userManager, SignInManager signInManager, IDepartmentSsoService departmentSsoService, - IStringLocalizer secLocalizer) + IStringLocalizer secLocalizer, IDeleteService deleteService) { _departmentsService = departmentsService; _usersService = usersService; @@ -72,6 +73,7 @@ public ProfileController(IDepartmentsService departmentsService, IUsersService u _signInManager = signInManager; _departmentSsoService = departmentSsoService; _secLocalizer = secLocalizer; + _deleteService = deleteService; } #endregion Private Members and Constructors @@ -1163,7 +1165,13 @@ public async Task DeleteDepartmentLink(int departmentId, Cancella { await _departmentsService.SetActiveDepartmentForUserAsync(UserId, defaultDepartment.DepartmentId, user, cancellationToken); - await _departmentsService.DeleteUserAsync(departmentToRemove.DepartmentId, UserId, UserId, cancellationToken); + var revoked = await _deleteService.RevokeDepartmentAccessAsync(UserId, departmentToRemove.DepartmentId, UserId, cancellationToken); + + if (!revoked) + { + Resgrid.Framework.Logging.LogError($"DeleteDepartmentLink: failed to revoke department {departmentToRemove.DepartmentId} access for user {UserId}"); + return RedirectToAction("YourDepartments"); + } await _signInManager.SignOutAsync(); @@ -1191,7 +1199,13 @@ await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, { await _departmentsService.SetActiveDepartmentForUserAsync(UserId, nextDepartmentUp.DepartmentId, user, cancellationToken); - await _departmentsService.DeleteUserAsync(departmentToRemove.DepartmentId, UserId, UserId, cancellationToken); + var revoked = await _deleteService.RevokeDepartmentAccessAsync(UserId, departmentToRemove.DepartmentId, UserId, cancellationToken); + + if (!revoked) + { + Resgrid.Framework.Logging.LogError($"DeleteDepartmentLink: failed to revoke department {departmentToRemove.DepartmentId} access for user {UserId}"); + return RedirectToAction("YourDepartments"); + } await _signInManager.SignOutAsync(); @@ -1215,7 +1229,10 @@ await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, { if (departmentToRemove.DepartmentId != defaultDepartment.DepartmentId) { - await _departmentsService.DeleteUserAsync(departmentToRemove.DepartmentId, UserId, UserId, cancellationToken); + var revoked = await _deleteService.RevokeDepartmentAccessAsync(UserId, departmentToRemove.DepartmentId, UserId, cancellationToken); + + if (!revoked) + Resgrid.Framework.Logging.LogError($"DeleteDepartmentLink: failed to revoke department {departmentToRemove.DepartmentId} access for user {UserId}"); } else { diff --git a/Web/Resgrid.Web/Areas/User/Views/Account/DeleteAccount.cshtml b/Web/Resgrid.Web/Areas/User/Views/Account/DeleteAccount.cshtml index dff7ea5ea..edfb0a4d4 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Account/DeleteAccount.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Account/DeleteAccount.cshtml @@ -43,6 +43,8 @@

@localizer["DeleteAreYouSure3"]

+ @localizer["DeleteAreYouSure4"] +


@if (!Model.IsDepartmentOwner) diff --git a/Workers/Resgrid.Workers.Console/Tasks/CallPruneTask.cs b/Workers/Resgrid.Workers.Console/Tasks/CallPruneTask.cs index cd8553e61..e9608fff3 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/CallPruneTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/CallPruneTask.cs @@ -49,6 +49,14 @@ public async Task ProcessAsync(CallPruneCommand command, IQuidjiboProgress progr item.PruneSettings.Department = await _departmentsService.GetDepartmentByIdAsync(item.PruneSettings.DepartmentId); + if (item.PruneSettings.Department == null) + { + // Orphaned pruning row for a deleted department; skip it so we don't + // null-ref in CallPruneLogic or spam error logs every run. + Resgrid.Framework.Logging.LogWarning($"CallPrune::Skipping orphaned pruning settings for deleted DepartmentId:{item.PruneSettings.DepartmentId}"); + continue; + } + var result = await logic.Process(item); if (result.Item1)