From a0a58253bffefce564c7e66af6e9f1a8f0fed549 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 11 Mar 2026 09:57:21 -0700 Subject: [PATCH 1/6] RE1-T46 bug fixes --- .../Servers/PostgreSql/PostgreSqlConfiguration.cs | 12 ++++++------ .../Servers/SqlServer/SqlServerConfiguration.cs | 12 ++++++------ .../Areas/User/Views/Personnel/AddPerson.cshtml | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index 02833eb3..f1973824 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -85,16 +85,16 @@ public PostgreSqlConfiguration() INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = al.UserId WHERE al.DestinationId = %CALLID% AND (al.ActionTypeId IS NULL OR al.ActionTypeId IN (%TYPES%))"; SelectPreviousActionLogsByUserQuery = @" - SELECT %SCHEMA%.%ACTIONLOGSTABLE%.*, %SCHEMA%.%ASPNETUSERSTABLE%.* + SELECT a1.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% a1 - INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% ON %SCHEMA%.%ASPNETUSERSTABLE%.Id = %SCHEMA%.%ACTIONLOGSTABLE%.UserId - WHERE a1.UserId = %USERID% AND ActionLogId < %ACTIONLOGID%"; + INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = a1.UserId + WHERE a1.UserId = %USERID% AND a1.ActionLogId < %ACTIONLOGID%"; SelectLastActionLogByUserIdQuery = @" - SELECT %SCHEMA%.%ACTIONLOGSTABLE%.*, %SCHEMA%.%ASPNETUSERSTABLE%.* + SELECT a1.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% a1 - INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% ON %SCHEMA%.%ASPNETUSERSTABLE%.Id = %SCHEMA%.%ACTIONLOGSTABLE%.UserId + INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = a1.UserId WHERE a1.UserId = %USERID% - ORDER BY ActionLogId DESC limit 1"; + ORDER BY a1.ActionLogId DESC limit 1"; SelectActionLogsByCallIdQuery = @" SELECT al.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% al diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index 5cbc7438..d656dba5 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -83,16 +83,16 @@ public SqlServerConfiguration() INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = al.UserId WHERE al.[DestinationId] = %CALLID% AND (al.[ActionTypeId] IS NULL OR al.[ActionTypeId] IN (%TYPES%))"; SelectPreviousActionLogsByUserQuery = @" - SELECT %SCHEMA%.%ACTIONLOGSTABLE%.*, %SCHEMA%.%ASPNETUSERSTABLE%.* + SELECT a1.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% a1 - INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% ON %SCHEMA%.%ASPNETUSERSTABLE%.Id = %SCHEMA%.%ACTIONLOGSTABLE%.UserId - WHERE a1.UserId = %USERID% AND [ActionLogId] < %ACTIONLOGID%"; + INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = a1.UserId + WHERE a1.UserId = %USERID% AND a1.[ActionLogId] < %ACTIONLOGID%"; SelectLastActionLogByUserIdQuery = @" - SELECT TOP 1 %SCHEMA%.%ACTIONLOGSTABLE%.*, %SCHEMA%.%ASPNETUSERSTABLE%.* + SELECT TOP 1 a1.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% a1 - INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% ON %SCHEMA%.%ASPNETUSERSTABLE%.Id = %SCHEMA%.%ACTIONLOGSTABLE%.UserId + INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = a1.UserId WHERE a1.UserId = %USERID% - ORDER BY ActionLogId DESC"; + ORDER BY a1.ActionLogId DESC"; SelectActionLogsByCallIdQuery = @" SELECT al.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% al diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/AddPerson.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/AddPerson.cshtml index d0bd9415..c1d3fa14 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/AddPerson.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/AddPerson.cshtml @@ -104,7 +104,7 @@
-
+
From acaa56ee08232dcaddd3a0c7036bfb5043688015 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 11 Mar 2026 11:51:10 -0700 Subject: [PATCH 2/6] RE1-T46 Bug fixes --- .../AzureRedisCacheProvider.cs | 12 +++++++----- .../LogsRepository.cs | 2 +- .../Areas/User/Controllers/ShiftsController.cs | 4 ++++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs b/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs index 5884010b..9c0354f6 100644 --- a/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs +++ b/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs @@ -36,10 +36,11 @@ public T Retrieve(string cacheKey, Func fallbackFunction, TimeSpan expirat if (cacheValue.HasValue) data = ObjectSerialization.Deserialize(cacheValue); } - catch + catch (Exception deserializeEx) { + Logging.LogException(deserializeEx); Remove(SetCacheKeyForEnv(cacheKey)); - throw; + data = null; } if (data != null) @@ -111,10 +112,11 @@ public async Task RetrieveAsync(string cacheKey, Func> fallbackFun if (cacheValue.HasValue) data = ObjectSerialization.Deserialize(cacheValue); } - catch + catch (Exception deserializeEx) { + Logging.LogException(deserializeEx); await RemoveAsync(SetCacheKeyForEnv(cacheKey)); - throw; + data = null; } if (data != null) @@ -157,7 +159,7 @@ public async Task SetStringAsync(string cacheKey, string value, TimeSpan e if (Config.SystemBehaviorConfig.CacheEnabled && _connection != null && _connection.IsConnected) { cache = _connection.GetDatabase(); - + if (value != null && cache != null) { try diff --git a/Repositories/Resgrid.Repositories.DataRepository/LogsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/LogsRepository.cs index e09bb512..d912d9e4 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/LogsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/LogsRepository.cs @@ -201,7 +201,7 @@ public async Task> GetAllLogsByDepartmentIdYearAsync(int depart { var dynamicParameters = new DynamicParametersExtension(); dynamicParameters.Add("DepartmentId", departmentId); - dynamicParameters.Add("Year", int.Parse(year)); + dynamicParameters.Add("Year", int.TryParse(year, out var parsedYear) ? parsedYear : DateTime.UtcNow.Year); var query = _queryFactory.GetQuery(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs index 25dcf584..48e3f117 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs @@ -1087,6 +1087,10 @@ public async Task GetShiftCalendarItemsForShift(int shiftId) { var calendarItems = new List(); var shift = await _shiftsService.GetShiftByIdAsync(shiftId); + + if (shift == null || shift.Days == null || !shift.Days.Any()) + return Json(calendarItems); + var allGroups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); var allRoles = await _personnelRolesService.GetRolesForDepartmentAsync(DepartmentId); var allUserNames = await _departmentService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); From ae8f1851ef0a5c4e1611cce152727959bc6ed900 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 11 Mar 2026 12:33:34 -0700 Subject: [PATCH 3/6] RE1-T46 Fixes --- .../Services/IAuthorizationService.cs | 10 +++ Core/Resgrid.Services/AuthorizationService.cs | 10 +++ .../User/Controllers/DepartmentController.cs | 89 +++++++++++++++---- .../User/Controllers/DocumentsController.cs | 3 +- .../Areas/User/Controllers/HomeController.cs | 66 ++++++++++---- .../Areas/User/Views/Documents/Index.cshtml | 48 +++------- .../Helpers/ClaimsAuthorizationHelper.cs | 5 ++ 7 files changed, 161 insertions(+), 70 deletions(-) diff --git a/Core/Resgrid.Model/Services/IAuthorizationService.cs b/Core/Resgrid.Model/Services/IAuthorizationService.cs index 2883d093..33883133 100644 --- a/Core/Resgrid.Model/Services/IAuthorizationService.cs +++ b/Core/Resgrid.Model/Services/IAuthorizationService.cs @@ -332,5 +332,15 @@ public interface IAuthorizationService Task CanUserManageWorkflowCredentialAsync(string userId, int departmentId); Task CanUserViewWorkflowRunsAsync(string userId, int departmentId); + + /// + /// Determines whether the specified user is a department admin or managing member of the given department. + /// Both and must be explicitly supplied by the + /// caller so the check is performed against the actual resource being modified, not just claims values. + /// + /// The user identifier to verify. + /// The department identifier of the resource being modified. + /// true if the user is the managing member or a department admin; otherwise false. + Task CanUserModifyDepartmentAsync(string userId, int departmentId); } } diff --git a/Core/Resgrid.Services/AuthorizationService.cs b/Core/Resgrid.Services/AuthorizationService.cs index 5d0b0f92..a7fcf11d 100644 --- a/Core/Resgrid.Services/AuthorizationService.cs +++ b/Core/Resgrid.Services/AuthorizationService.cs @@ -1516,5 +1516,15 @@ public async Task CanUserViewWorkflowRunsAsync(string userId, int departme return _permissionsService.IsUserAllowed(permission, department != null && department.IsUserAnAdmin(userId), isGroupAdmin, roles); } + public async Task CanUserModifyDepartmentAsync(string userId, int departmentId) + { + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + + if (department == null) + return false; + + return department.IsUserAnAdmin(userId); + } + } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index 89a62d2b..85672210 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -378,9 +378,11 @@ public async Task Settings(DepartmentSettingsModel model, IFormCo Department d = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); auditEvent.Before = d.CloneJsonToString(); + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, d.DepartmentId)) + return Unauthorized(); + d.TimeZone = model.Department.TimeZone; d.Name = model.Department.Name; - d.ManagingUserId = model.Department.ManagingUserId; d.Use24HourTime = model.Use24HourTime; ViewBag.Countries = new SelectList(Countries.CountryNames); @@ -421,12 +423,22 @@ public async Task Settings(DepartmentSettingsModel model, IFormCo Address departmentAddress = null; if (model.Department.Address.AddressId != 0) { - departmentAddress = await _addressService.GetAddressByIdAsync(model.Department.Address.AddressId); - departmentAddress.Address1 = model.Department.Address.Address1; - departmentAddress.City = model.Department.Address.City; - departmentAddress.State = model.Department.Address.State; - departmentAddress.PostalCode = model.Department.Address.PostalCode; - departmentAddress.Country = model.Department.Address.Country; + // IDOR fix: verify the submitted address ID belongs to the authenticated user's department + if (d.AddressId.HasValue && d.AddressId.Value == model.Department.Address.AddressId) + { + departmentAddress = await _addressService.GetAddressByIdAsync(model.Department.Address.AddressId); + departmentAddress.Address1 = model.Department.Address.Address1; + departmentAddress.City = model.Department.Address.City; + departmentAddress.State = model.Department.Address.State; + departmentAddress.PostalCode = model.Department.Address.PostalCode; + departmentAddress.Country = model.Department.Address.Country; + } + else + { + // The submitted AddressId does not belong to this department; treat as a new address + departmentAddress = model.Department.Address; + departmentAddress.AddressId = 0; + } } else departmentAddress = model.Department.Address; @@ -579,17 +591,18 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, newAddre task.Friday = true; task.Saturday = true; task.Sunday = true; - task.UserId = model.Department.ManagingUserId; - task.DepartmentId = model.Department.DepartmentId; + // IDOR fix: use server-authoritative values, not model-bound ones + task.UserId = d.ManagingUserId; + task.DepartmentId = DepartmentId; - await _scheduledTasksService.DeleteDepartmentStaffingResetJobAsync(model.Department.DepartmentId, cancellationToken); + await _scheduledTasksService.DeleteDepartmentStaffingResetJobAsync(DepartmentId, cancellationToken); await _scheduledTasksService.SaveScheduledTaskAsync(task, cancellationToken); } else { model.ResetStaffingTo = (int)UserStateTypes.Available; model.TimeToResetStaffing = String.Empty; - await _scheduledTasksService.DeleteDepartmentStaffingResetJobAsync(model.Department.DepartmentId, cancellationToken); + await _scheduledTasksService.DeleteDepartmentStaffingResetJobAsync(DepartmentId, cancellationToken); } if (model.EnableStatusReset) @@ -607,17 +620,18 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, newAddre task.Friday = true; task.Saturday = true; task.Sunday = true; - task.UserId = model.Department.ManagingUserId; - task.DepartmentId = model.Department.DepartmentId; + // IDOR fix: use server-authoritative values, not model-bound ones + task.UserId = d.ManagingUserId; + task.DepartmentId = DepartmentId; - await _scheduledTasksService.DeleteDepartmentStatusResetJob(model.Department.DepartmentId, cancellationToken); + await _scheduledTasksService.DeleteDepartmentStatusResetJob(DepartmentId, cancellationToken); await _scheduledTasksService.SaveScheduledTaskAsync(task, cancellationToken); } else { model.ResetStatusTo = (int)ActionTypes.StandingBy; model.TimeToResetStatus = String.Empty; - await _scheduledTasksService.DeleteDepartmentStatusResetJob(model.Department.DepartmentId, cancellationToken); + await _scheduledTasksService.DeleteDepartmentStatusResetJob(DepartmentId, cancellationToken); } _eventAggregator.SendMessage(new DepartmentSettingsChangedEvent() { DepartmentId = DepartmentId }); @@ -651,6 +665,9 @@ public async Task Api() [Authorize(Policy = ResgridResources.Department_Update)] public async Task ProvisionApiKey(CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + DepartmentSettingsModel model = new DepartmentSettingsModel(); Department d = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); @@ -666,6 +683,9 @@ public async Task ProvisionApiKey(CancellationToken cancellationT [Authorize(Policy = ResgridResources.Department_Update)] public async Task ProvisionApiKeyAsync(CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + Department d = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); d.ApiKey = Guid.NewGuid().ToString("N"); await _departmentsService.UpdateDepartmentAsync(d, cancellationToken); @@ -677,6 +697,9 @@ public async Task ProvisionApiKeyAsync(CancellationToken cancella [Authorize(Policy = ResgridResources.Department_Update)] public async Task ProvisionActiveCallRssKey(CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, Guid.NewGuid().ToString("N"), DepartmentSettingTypes.RssFeedKeyForActiveCalls, cancellationToken); @@ -706,10 +729,20 @@ public async Task Address() [Authorize(Policy = ResgridResources.Department_Update)] public async Task Address(SettingsAddressModel model, CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + // IDOR fix: always load the department from the server using the authenticated user's DepartmentId + var currentDepartment = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + Address address = null; if (model.Department.Address.AddressId != 0) { + // IDOR fix: only allow editing the address if its ID matches the one stored on the department record + if (!currentDepartment.AddressId.HasValue || currentDepartment.AddressId.Value != model.Department.Address.AddressId) + return Unauthorized(); + address = await _addressService.GetAddressByIdAsync(model.Department.Address.AddressId); address.AddressId = model.Department.Address.AddressId; address.Address1 = model.Department.Address.Address1; @@ -721,7 +754,7 @@ public async Task Address(SettingsAddressModel model, Cancellatio else address = model.Department.Address; - model.Department = await _departmentsService.GetDepartmentByUserIdAsync(UserId); + model.Department = currentDepartment; model.User = _usersService.GetUserById(UserId); @@ -833,6 +866,9 @@ public async Task CallSettings() [Authorize(Policy = ResgridResources.Department_Update)] public async Task CallSettings(CallSettingsView model, CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + model.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); @@ -1166,6 +1202,9 @@ public async Task TextSettings() [Authorize(Policy = ResgridResources.Department_Update)] public async Task TextSettings(TextSetupModel model, CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + model.CanProvisionNumber = await _limitsService.CanDepartmentProvisionNumberAsync(DepartmentId); model.DepartmentTextToCallNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(DepartmentId); @@ -1498,6 +1537,9 @@ public async Task SetupWizard() [Authorize(Policy = ResgridResources.Department_Update)] public async Task SubmitSetupWizard([FromBody] SetupWizardFormPayload payload, CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + var formCollection = JsonConvert.DeserializeObject>(payload.setupWizardForm); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); @@ -1596,6 +1638,9 @@ public async Task ShiftSettings() [Authorize(Policy = ResgridResources.Department_Update)] public async Task ShiftSettings(ShiftSettingsView model, CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + if (ModelState.IsValid) { await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.AllowSignupsForMultipleShiftGroups.ToString(), @@ -1651,6 +1696,9 @@ public async Task DispatchSettings() [Authorize(Policy = ResgridResources.Department_Update)] public async Task DispatchSettings(DispatchSettingsView model, CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + var actionLogs = await _customStateService.GetActivePersonnelStateForDepartmentAsync(DepartmentId); if (actionLogs == null) { @@ -1717,6 +1765,9 @@ public async Task MappingSettings() [Authorize(Policy = ResgridResources.Department_Update)] public async Task MappingSettings(MappingSettingsView model, CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + if (ModelState.IsValid) { await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.PersonnelLocationTTL.ToString(), @@ -1760,6 +1811,9 @@ public async Task DeleteDepartment() [Authorize(Policy = ResgridResources.Department_Update)] public async Task DeleteDepartment(DeleteDepartmentView model, CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + if (model.AreYouSure == false) ModelState.AddModelError("AreYouSure", "You need to confirm the delete."); @@ -1847,6 +1901,9 @@ public async Task ModuleSettings() [Authorize(Policy = ResgridResources.Department_Update)] public async Task ModuleSettings(DepartmentModulesSettingView model, CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + if (ModelState.IsValid) { var modules = await _departmentSettingsService.GetDepartmentModuleSettingsAsync(DepartmentId); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DocumentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DocumentsController.cs index 45f4e4be..a3d6152c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DocumentsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DocumentsController.cs @@ -91,7 +91,8 @@ public async Task GetDocument(int documentId) }; } - [HttpGet] + [HttpPost] + [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Documents_Delete)] public async Task DeleteDocument(int documentId, CancellationToken cancellationToken) { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index dae49287..47c16798 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -493,9 +493,23 @@ public async Task EditUserProfile(string userId) [Authorize(Policy = ResgridResources.Department_View)] public async Task EditUserProfile(EditProfileModel model, IFormCollection form, CancellationToken cancellationToken) { + // Re-derive the target user identity server-side; never trust the model-bound UserId + // if it was not explicitly provided (e.g. editing own profile via direct navigation). + if (string.IsNullOrWhiteSpace(model.UserId)) + model.UserId = UserId; + if (!await _authorizationService.CanUserEditProfileAsync(UserId, DepartmentId, model.UserId)) return Unauthorized(); + // SECURITY: Derive IsOwnProfile server-side — never trust the form-posted value. + // An attacker could submit IsOwnProfile=true to gain access to the password/username + // change code paths while targeting another user's profile. + model.IsOwnProfile = model.UserId == UserId; + + // Determine the caller's privilege level server-side for use throughout this action. + bool callerIsDepartmentAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + bool callerIsGroupAdmin = await _departmentGroupsService.IsUserAGroupAdminAsync(UserId, DepartmentId); + model.User = _usersService.GetUserById(model.UserId); //model.PushUris = await _pushUriService.GetPushUrisByUserId(model.UserId); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); @@ -574,10 +588,20 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo if (model.User.Email != model.Email) { - var currentEmail = _usersService.GetUserByEmail(model.Email); + // SECURITY: Email changes are high-privilege — only the account owner or a department + // admin may change an email address. A group admin must NOT be able to change a + // member's email because that enables account-takeover via the password-reset flow. + if (!model.IsOwnProfile && !callerIsDepartmentAdmin) + { + ModelState.AddModelError("Email", "You do not have permission to change this user's email address."); + } + else + { + var currentEmail = _usersService.GetUserByEmail(model.Email); - if (currentEmail != null && currentEmail.Id != model.User.UserId.ToString()) - ModelState.AddModelError("Email", "Email Address Already in Use. Please use another one."); + if (currentEmail != null && currentEmail.Id != model.User.UserId.ToString()) + ModelState.AddModelError("Email", "Email Address Already in Use. Please use another one."); + } } if (model.Profile.VoiceForCall) @@ -691,7 +715,7 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo savedProfile.VoiceCallMobile = false; } - if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + if (callerIsDepartmentAdmin) { var currentGroup = await _departmentGroupsService.GetGroupForUserAsync(model.UserId, DepartmentId); if (model.UserGroup != 0 && (currentGroup == null || currentGroup.DepartmentGroupId != model.UserGroup)) @@ -776,18 +800,26 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo var depMember = await _departmentsService.GetDepartmentMemberAsync(model.UserId, DepartmentId); if (depMember != null) { - // Users Department Admin status changes, invalid the department object in cache. - if (model.IsDepartmentAdmin != depMember.IsAdmin) - _departmentsService.InvalidateDepartmentInCache(depMember.DepartmentId); + // SECURITY: Only department admins may change administrative flags on a member. + // Group admins can edit profile data but must not be able to promote/demote + // department admins, disable users, or hide them from the roster. + if (callerIsDepartmentAdmin) + { + // Users Department Admin status changes, invalid the department object in cache. + if (model.IsDepartmentAdmin != depMember.IsAdmin) + _departmentsService.InvalidateDepartmentInCache(depMember.DepartmentId); - depMember.IsAdmin = model.IsDepartmentAdmin; - depMember.IsDisabled = model.IsDisabled; - depMember.IsHidden = model.IsHidden; + depMember.IsAdmin = model.IsDepartmentAdmin; + depMember.IsDisabled = model.IsDisabled; + depMember.IsHidden = model.IsHidden; + } await _departmentsService.SaveDepartmentMemberAsync(depMember, cancellationToken); } - _usersService.UpdateEmail(model.User.Id, model.Email); + // SECURITY: Only the account owner or a department admin may update the email address. + if (model.IsOwnProfile || callerIsDepartmentAdmin) + _usersService.UpdateEmail(model.User.Id, model.Email); if (model.IsOwnProfile) { @@ -823,8 +855,8 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo Value = form[k] }).ToList(); - bool isDeptAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); - bool isGroupAdmin = await _departmentGroupsService.IsUserAGroupAdminAsync(UserId, DepartmentId); + bool isDeptAdmin = callerIsDepartmentAdmin; + bool isGroupAdmin = callerIsGroupAdmin; var udfValidationErrors = await _userDefinedFieldsService.SaveFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Personnel, model.UserId, udfValues, UserId, isDeptAdmin, isGroupAdmin, cancellationToken); if (udfValidationErrors != null && udfValidationErrors.Count > 0) @@ -843,8 +875,8 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo var udfDefinitionOnUdfError = await _userDefinedFieldsService.GetActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Personnel); if (udfDefinitionOnUdfError != null) { - bool isDeptAdminOnUdfError = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); - bool isGroupAdminOnUdfError = await _departmentGroupsService.IsUserAGroupAdminAsync(UserId, DepartmentId); + bool isDeptAdminOnUdfError = callerIsDepartmentAdmin; + bool isGroupAdminOnUdfError = callerIsGroupAdmin; var udfFieldsOnUdfError = await _userDefinedFieldsService.GetVisibleFieldsForActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Personnel, isDeptAdminOnUdfError, isGroupAdminOnUdfError); var udfValuesOnUdfError = await _userDefinedFieldsService.GetFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Personnel, model.UserId); var visibleFieldIdsOnUdfError = udfFieldsOnUdfError.Select(f => f.UdfFieldId).ToHashSet(); @@ -881,8 +913,8 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo var udfDefinitionOnFailure = await _userDefinedFieldsService.GetActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Personnel); if (udfDefinitionOnFailure != null) { - bool isDeptAdminOnFailure = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); - bool isGroupAdminOnFailure = await _departmentGroupsService.IsUserAGroupAdminAsync(UserId, DepartmentId); + bool isDeptAdminOnFailure = callerIsDepartmentAdmin; + bool isGroupAdminOnFailure = callerIsGroupAdmin; var udfFieldsOnFailure = await _userDefinedFieldsService.GetVisibleFieldsForActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Personnel, isDeptAdminOnFailure, isGroupAdminOnFailure); var udfValuesOnFailure = await _userDefinedFieldsService.GetFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Personnel, model.UserId); var visibleFieldIdsOnFailure = udfFieldsOnFailure.Select(f => f.UdfFieldId).ToHashSet(); diff --git a/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml index 753d827c..5b4640a6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml @@ -74,44 +74,20 @@ @commonLocalizer["Added"]: @doc.AddedOn.TimeConverterToString(Model.Department) + @if (ClaimsAuthorizationHelper.CanDeleteDocument() && (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || doc.UserId == Model.UserId)) + { +
+ @Html.AntiForgeryToken() + + +
+ } - - @* - - @doc.Name - - - @doc.Category - - - @Html.TimeConverterToString(doc.AddedOn, Model.Department) - - -
- - -
- - - *@ } diff --git a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs index 4bcd76b9..49988863 100644 --- a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs @@ -149,6 +149,11 @@ public static bool CanCreateDocument() return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Documents, ResgridClaimTypes.Actions.Create); } + public static bool CanDeleteDocument() + { + return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Documents, ResgridClaimTypes.Actions.Delete); + } + public static bool CanCreateNote() { return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Notes, ResgridClaimTypes.Actions.Create); From 7fc8f3f33e3105ac0bb17bd29a41b3b011cd753d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 11 Mar 2026 13:00:29 -0700 Subject: [PATCH 4/6] RE1-T46 PR#296 fixes --- Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs | 4 ++-- .../Servers/PostgreSql/PostgreSqlConfiguration.cs | 3 ++- .../Servers/SqlServer/SqlServerConfiguration.cs | 5 +++-- Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs | 2 +- Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml | 1 + 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs b/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs index 9c0354f6..7f80ecf8 100644 --- a/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs +++ b/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs @@ -39,7 +39,7 @@ public T Retrieve(string cacheKey, Func fallbackFunction, TimeSpan expirat catch (Exception deserializeEx) { Logging.LogException(deserializeEx); - Remove(SetCacheKeyForEnv(cacheKey)); + Remove(cacheKey); data = null; } @@ -115,7 +115,7 @@ public async Task RetrieveAsync(string cacheKey, Func> fallbackFun catch (Exception deserializeEx) { Logging.LogException(deserializeEx); - await RemoveAsync(SetCacheKeyForEnv(cacheKey)); + await RemoveAsync(cacheKey); data = null; } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index f1973824..5fc7ffbd 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -88,7 +88,8 @@ public PostgreSqlConfiguration() SELECT a1.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% a1 INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = a1.UserId - WHERE a1.UserId = %USERID% AND a1.ActionLogId < %ACTIONLOGID%"; + WHERE a1.UserId = %USERID% AND a1.ActionLogId < %ACTIONLOGID% + ORDER BY a1.ActionLogId DESC LIMIT 1"; SelectLastActionLogByUserIdQuery = @" SELECT a1.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% a1 diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index d656dba5..9724bc25 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -83,10 +83,11 @@ public SqlServerConfiguration() INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = al.UserId WHERE al.[DestinationId] = %CALLID% AND (al.[ActionTypeId] IS NULL OR al.[ActionTypeId] IN (%TYPES%))"; SelectPreviousActionLogsByUserQuery = @" - SELECT a1.*, u.* + SELECT TOP 1 a1.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% a1 INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = a1.UserId - WHERE a1.UserId = %USERID% AND a1.[ActionLogId] < %ACTIONLOGID%"; + WHERE a1.UserId = %USERID% AND a1.[ActionLogId] < %ACTIONLOGID% + ORDER BY a1.[ActionLogId] DESC"; SelectLastActionLogByUserIdQuery = @" SELECT TOP 1 a1.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% a1 diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs index 48e3f117..67374832 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ShiftsController.cs @@ -1088,7 +1088,7 @@ public async Task GetShiftCalendarItemsForShift(int shiftId) var calendarItems = new List(); var shift = await _shiftsService.GetShiftByIdAsync(shiftId); - if (shift == null || shift.Days == null || !shift.Days.Any()) + if (shift == null || shift.DepartmentId != DepartmentId || shift.Days == null || !shift.Days.Any()) return Json(calendarItems); var allGroups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); diff --git a/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml index 5b4640a6..634c79c5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml @@ -97,3 +97,4 @@ +} From 84105a49e66db1919cb57aa2b6ea2210ade6e277 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 11 Mar 2026 13:20:22 -0700 Subject: [PATCH 5/6] RE1-T46 PR#296 fixes --- .../Repositories/IWorkflowRepository.cs | 9 ++- Core/Resgrid.Services/WorkflowService.cs | 29 +++++++--- .../WorkflowRepository.cs | 56 +++++++++++++++++++ 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/Core/Resgrid.Model/Repositories/IWorkflowRepository.cs b/Core/Resgrid.Model/Repositories/IWorkflowRepository.cs index a22d9062..b4a5e564 100644 --- a/Core/Resgrid.Model/Repositories/IWorkflowRepository.cs +++ b/Core/Resgrid.Model/Repositories/IWorkflowRepository.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading.Tasks; namespace Resgrid.Model.Repositories @@ -8,6 +8,13 @@ public interface IWorkflowRepository : IRepository Task> GetAllActiveByDepartmentAndEventTypeAsync(int departmentId, int triggerEventType); Task> GetAllByDepartmentIdAsync(int departmentId); Task GetByDepartmentAndEventTypeAsync(int departmentId, int triggerEventType); + + /// + /// Atomically deletes a workflow and all its dependent child records (WorkflowRunLogs, + /// WorkflowRuns, WorkflowSteps) within a single database transaction, preventing FK + /// constraint violations caused by concurrent run inserts racing with deletion. + /// + Task DeleteWorkflowWithAllDependenciesAsync(string workflowId); } } diff --git a/Core/Resgrid.Services/WorkflowService.cs b/Core/Resgrid.Services/WorkflowService.cs index b0e387b1..de1b0f2b 100644 --- a/Core/Resgrid.Services/WorkflowService.cs +++ b/Core/Resgrid.Services/WorkflowService.cs @@ -88,15 +88,11 @@ public async Task DeleteWorkflowAsync(string workflowId, CancellationToken var workflow = await _workflowRepository.GetByIdAsync(workflowId); if (workflow == null) return false; - // Delete child records in dependency order to avoid FK constraint violations: - // 1. WorkflowRunLogs (references WorkflowRuns) - await _runLogRepository.DeleteAllByWorkflowIdAsync(workflowId); - // 2. WorkflowRuns (references Workflows) - await _runRepository.DeleteAllByWorkflowIdAsync(workflowId); - // 3. WorkflowSteps (references Workflows) - await _stepRepository.DeleteAllByWorkflowIdAsync(workflowId); - // 4. Workflow itself - await _workflowRepository.DeleteAsync(workflow, cancellationToken); + // Atomically delete all child records (WorkflowRunLogs → WorkflowRuns → WorkflowSteps) + // and the workflow itself within a single database transaction. This prevents the + // FK_WorkflowRuns_Workflows constraint violation that occurs when a background worker + // inserts a new WorkflowRun between the sequential per-table deletes. + await _workflowRepository.DeleteWorkflowWithAllDependenciesAsync(workflowId); return true; } @@ -172,6 +168,21 @@ public async Task SaveWorkflowStepAsync(WorkflowStep step, Cancell step.CreatedOn = DateTime.UtcNow; return await _stepRepository.InsertAsync(step, cancellationToken); } + + // Fetch the existing record to preserve immutable audit fields (CreatedOn, CreatedByUserId) + // so that DateTime.MinValue is never passed to SQL Server. + var existing = await _stepRepository.GetByIdAsync(step.WorkflowStepId); + if (existing != null) + { + step.CreatedOn = existing.CreatedOn; + step.CreatedByUserId = existing.CreatedByUserId; + } + else if (step.CreatedOn == default) + { + // Fallback: prevents SqlDateTime overflow when existing record is not found + step.CreatedOn = DateTime.UtcNow; + } + step.UpdatedOn = DateTime.UtcNow; await _stepRepository.UpdateAsync(step, cancellationToken); return step; diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRepository.cs index 6c2e34ec..90199183 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRepository.cs @@ -9,6 +9,8 @@ using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; using Resgrid.Repositories.DataRepository.Configs; +using System.Data; +using Resgrid.Config; using Resgrid.Repositories.DataRepository.Queries.Workflows; namespace Resgrid.Repositories.DataRepository @@ -132,6 +134,60 @@ public async Task GetByDepartmentAndEventTypeAsync(int departmentId, i throw; } } + + /// + public async Task DeleteWorkflowWithAllDependenciesAsync(string workflowId) + { + try + { + using (var conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + using (var transaction = conn.BeginTransaction(IsolationLevel.ReadCommitted)) + { + try + { + var dp = new DynamicParametersExtension(); + dp.Add("WorkflowId", workflowId); + + // 1. Delete WorkflowRunLogs (child of WorkflowRuns) + var deleteRunLogsQuery = _queryFactory.GetDeleteQuery(); + await conn.ExecuteAsync(sql: deleteRunLogsQuery, param: dp, transaction: transaction); + + // 2. Delete WorkflowRuns (child of Workflows) — deleted after logs so no FK violations + var deleteRunsQuery = _queryFactory.GetDeleteQuery(); + await conn.ExecuteAsync(sql: deleteRunsQuery, param: dp, transaction: transaction); + + // 3. Delete WorkflowSteps (child of Workflows) + var deleteStepsQuery = _queryFactory.GetDeleteQuery(); + await conn.ExecuteAsync(sql: deleteStepsQuery, param: dp, transaction: transaction); + + // 4. Delete the Workflow itself + string deleteWorkflowSql; + if (DataConfig.DatabaseType == DatabaseTypes.Postgres) + deleteWorkflowSql = $"DELETE FROM {_sqlConfiguration.SchemaName}.workflows WHERE workflowid = {_sqlConfiguration.ParameterNotation}WorkflowId"; + else + deleteWorkflowSql = $"DELETE FROM {_sqlConfiguration.SchemaName}.[Workflows] WHERE [WorkflowId] = {_sqlConfiguration.ParameterNotation}WorkflowId"; + + await conn.ExecuteAsync(sql: deleteWorkflowSql, param: dp, transaction: transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } } } From a3024b488aa0cce88b27f38e795200a0ac2c72d2 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 11 Mar 2026 14:03:11 -0700 Subject: [PATCH 6/6] RE1-T46 PR#296 fixes --- Core/Resgrid.Services/WorkflowService.cs | 17 +- .../WorkflowRepository.cs | 98 ++- ...orkflowRepositoryDeleteConcurrencyTests.cs | 607 ++++++++++++++++++ 3 files changed, 681 insertions(+), 41 deletions(-) create mode 100644 Tests/Resgrid.Tests/Services/WorkflowRepositoryDeleteConcurrencyTests.cs diff --git a/Core/Resgrid.Services/WorkflowService.cs b/Core/Resgrid.Services/WorkflowService.cs index de1b0f2b..b82fbe21 100644 --- a/Core/Resgrid.Services/WorkflowService.cs +++ b/Core/Resgrid.Services/WorkflowService.cs @@ -169,20 +169,13 @@ public async Task SaveWorkflowStepAsync(WorkflowStep step, Cancell return await _stepRepository.InsertAsync(step, cancellationToken); } - // Fetch the existing record to preserve immutable audit fields (CreatedOn, CreatedByUserId) - // so that DateTime.MinValue is never passed to SQL Server. + // Fetch the existing record to preserve immutable audit fields (CreatedOn, CreatedByUserId). var existing = await _stepRepository.GetByIdAsync(step.WorkflowStepId); - if (existing != null) - { - step.CreatedOn = existing.CreatedOn; - step.CreatedByUserId = existing.CreatedByUserId; - } - else if (step.CreatedOn == default) - { - // Fallback: prevents SqlDateTime overflow when existing record is not found - step.CreatedOn = DateTime.UtcNow; - } + if (existing == null) + throw new KeyNotFoundException($"WorkflowStep '{step.WorkflowStepId}' was not found. It may have been deleted or the ID is stale."); + step.CreatedOn = existing.CreatedOn; + step.CreatedByUserId = existing.CreatedByUserId; step.UpdatedOn = DateTime.UtcNow; await _stepRepository.UpdateAsync(step, cancellationToken); return step; diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRepository.cs index 90199183..3264177c 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRepository.cs @@ -140,44 +140,84 @@ public async Task DeleteWorkflowWithAllDependenciesAsync(string workflowId) { try { - using (var conn = _connectionProvider.Create()) + var dp = new DynamicParametersExtension(); + dp.Add("WorkflowId", workflowId); + + // Lock SQL: acquired inside the transaction before child deletes so that no + // concurrent WorkflowRun INSERT can slip in between the child deletes and the + // parent delete, which would cause an FK violation. + string lockWorkflowSql; + string deleteWorkflowSql; + if (DataConfig.DatabaseType == DatabaseTypes.Postgres) { - await conn.OpenAsync(); + lockWorkflowSql = $"SELECT workflowid FROM {_sqlConfiguration.SchemaName}.workflows WHERE workflowid = {_sqlConfiguration.ParameterNotation}WorkflowId FOR UPDATE"; + deleteWorkflowSql = $"DELETE FROM {_sqlConfiguration.SchemaName}.workflows WHERE workflowid = {_sqlConfiguration.ParameterNotation}WorkflowId"; + } + else + { + lockWorkflowSql = $"SELECT [WorkflowId] FROM {_sqlConfiguration.SchemaName}.[Workflows] WITH (UPDLOCK, HOLDLOCK) WHERE [WorkflowId] = {_sqlConfiguration.ParameterNotation}WorkflowId"; + deleteWorkflowSql = $"DELETE FROM {_sqlConfiguration.SchemaName}.[Workflows] WHERE [WorkflowId] = {_sqlConfiguration.ParameterNotation}WorkflowId"; + } - using (var transaction = conn.BeginTransaction(IsolationLevel.ReadCommitted)) - { - try - { - var dp = new DynamicParametersExtension(); - dp.Add("WorkflowId", workflowId); + if (_unitOfWork?.Connection != null) + { + // Enlist in the caller's ambient unit-of-work — reuse its connection and + // transaction so the deletes participate in the same transaction scope and + // do not commit independently. + var conn = _unitOfWork.CreateOrGetConnection(); + var transaction = _unitOfWork.Transaction; - // 1. Delete WorkflowRunLogs (child of WorkflowRuns) - var deleteRunLogsQuery = _queryFactory.GetDeleteQuery(); - await conn.ExecuteAsync(sql: deleteRunLogsQuery, param: dp, transaction: transaction); + // Lock the parent row before touching any child tables so that concurrent + // run inserts targeting this workflow are blocked for the duration of the + // transaction, preventing FK constraint violations. + await conn.ExecuteAsync(sql: lockWorkflowSql, param: dp, transaction: transaction); - // 2. Delete WorkflowRuns (child of Workflows) — deleted after logs so no FK violations - var deleteRunsQuery = _queryFactory.GetDeleteQuery(); - await conn.ExecuteAsync(sql: deleteRunsQuery, param: dp, transaction: transaction); + var deleteRunLogsQuery = _queryFactory.GetDeleteQuery(); + await conn.ExecuteAsync(sql: deleteRunLogsQuery, param: dp, transaction: transaction); - // 3. Delete WorkflowSteps (child of Workflows) - var deleteStepsQuery = _queryFactory.GetDeleteQuery(); - await conn.ExecuteAsync(sql: deleteStepsQuery, param: dp, transaction: transaction); + var deleteRunsQuery = _queryFactory.GetDeleteQuery(); + await conn.ExecuteAsync(sql: deleteRunsQuery, param: dp, transaction: transaction); - // 4. Delete the Workflow itself - string deleteWorkflowSql; - if (DataConfig.DatabaseType == DatabaseTypes.Postgres) - deleteWorkflowSql = $"DELETE FROM {_sqlConfiguration.SchemaName}.workflows WHERE workflowid = {_sqlConfiguration.ParameterNotation}WorkflowId"; - else - deleteWorkflowSql = $"DELETE FROM {_sqlConfiguration.SchemaName}.[Workflows] WHERE [WorkflowId] = {_sqlConfiguration.ParameterNotation}WorkflowId"; + var deleteStepsQuery = _queryFactory.GetDeleteQuery(); + await conn.ExecuteAsync(sql: deleteStepsQuery, param: dp, transaction: transaction); - await conn.ExecuteAsync(sql: deleteWorkflowSql, param: dp, transaction: transaction); + await conn.ExecuteAsync(sql: deleteWorkflowSql, param: dp, transaction: transaction); + } + else + { + // No ambient unit-of-work — open a dedicated connection and manage a local + // transaction to keep the multi-step delete atomic. + using (var conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); - transaction.Commit(); - } - catch + using (var transaction = conn.BeginTransaction(IsolationLevel.ReadCommitted)) { - transaction.Rollback(); - throw; + try + { + // Lock the parent row before touching any child tables so that + // concurrent run inserts targeting this workflow are blocked for + // the duration of this transaction, preventing FK violations. + await conn.ExecuteAsync(sql: lockWorkflowSql, param: dp, transaction: transaction); + + var deleteRunLogsQuery = _queryFactory.GetDeleteQuery(); + await conn.ExecuteAsync(sql: deleteRunLogsQuery, param: dp, transaction: transaction); + + var deleteRunsQuery = _queryFactory.GetDeleteQuery(); + await conn.ExecuteAsync(sql: deleteRunsQuery, param: dp, transaction: transaction); + + var deleteStepsQuery = _queryFactory.GetDeleteQuery(); + await conn.ExecuteAsync(sql: deleteStepsQuery, param: dp, transaction: transaction); + + await conn.ExecuteAsync(sql: deleteWorkflowSql, param: dp, transaction: transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } } } } diff --git a/Tests/Resgrid.Tests/Services/WorkflowRepositoryDeleteConcurrencyTests.cs b/Tests/Resgrid.Tests/Services/WorkflowRepositoryDeleteConcurrencyTests.cs new file mode 100644 index 00000000..6151c2b1 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkflowRepositoryDeleteConcurrencyTests.cs @@ -0,0 +1,607 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries.Workflows; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; + +namespace Resgrid.Tests.Services +{ + /// + /// Tests that acquires a + /// row-level lock on the parent Workflows row before deleting any child rows, and that the + /// generated lock SQL is correct for both PostgreSQL and SQL Server. + /// + /// These are unit-level integration tests: they mock the Dapper-level connection so that no live + /// database is required, but they verify the *exact call-order* and *SQL text* that would be + /// sent to a real database engine. The concurrency scenario is also simulated by interleaving + /// two Tasks that share a monitor/gate, demonstrating that an inserter waits while the deleter + /// holds the lock. + /// + [TestFixture] + public class WorkflowRepositoryDeleteConcurrencyTests + { + // ── helpers ──────────────────────────────────────────────────────────────────── + + /// + /// Returns a SqlConfiguration whose SchemaName and ParameterNotation match the + /// real concrete configuration class for the given database type. + /// + private static SqlConfiguration BuildConfig(DatabaseTypes dbType) + { + if (dbType == DatabaseTypes.Postgres) + return new PostgreSqlConfiguration(); // SchemaName="public", ParameterNotation="@" + + return new SqlServerConfiguration(); // SchemaName="[dbo]", ParameterNotation="@" + } + + /// + /// Builds the expected lock SQL string so tests don't hard-code the schema name. + /// + private static string ExpectedLockSql(DatabaseTypes dbType, SqlConfiguration cfg) + { + return dbType == DatabaseTypes.Postgres + ? $"SELECT workflowid FROM {cfg.SchemaName}.workflows WHERE workflowid = {cfg.ParameterNotation}WorkflowId FOR UPDATE" + : $"SELECT [WorkflowId] FROM {cfg.SchemaName}.[Workflows] WITH (UPDLOCK, HOLDLOCK) WHERE [WorkflowId] = {cfg.ParameterNotation}WorkflowId"; + } + + // ── lock-SQL generation tests ────────────────────────────────────────────────── + + [Test] + public void LockSql_ForPostgres_ContainsForUpdate() + { + var cfg = BuildConfig(DatabaseTypes.Postgres); + var sql = ExpectedLockSql(DatabaseTypes.Postgres, cfg); + + sql.Should().Contain("FOR UPDATE", "PostgreSQL row lock must use FOR UPDATE"); + sql.Should().Contain("public.workflows", "schema and table name must be lower-cased for Postgres"); + sql.Should().Contain("@WorkflowId", "parameter should use @ notation"); + } + + [Test] + public void LockSql_ForSqlServer_ContainsUpdlockHoldlock() + { + var cfg = BuildConfig(DatabaseTypes.SqlServer); + var sql = ExpectedLockSql(DatabaseTypes.SqlServer, cfg); + + sql.Should().Contain("WITH (UPDLOCK, HOLDLOCK)", "SQL Server row lock must use UPDLOCK + HOLDLOCK hints"); + sql.Should().Contain("[dbo].[Workflows]", "schema and table name must be bracket-quoted for SQL Server"); + sql.Should().Contain("@WorkflowId", "parameter should use @ notation"); + } + + // ── call-order tests (standalone transaction path) ───────────────────────────── + + /// + /// Verifies that when no ambient UoW exists, the lock SELECT is the very first + /// statement executed on the connection before any DELETE is issued. + /// + [Test] + [TestCase(DatabaseTypes.Postgres)] + [TestCase(DatabaseTypes.SqlServer)] + public async Task StandalonePath_LockSelectIsFirstStatementExecuted(DatabaseTypes dbType) + { + // Arrange + var originalDbType = DataConfig.DatabaseType; + DataConfig.DatabaseType = dbType; + + try + { + var cfg = BuildConfig(dbType); + var executedSqls = new List(); + + // CapturingConnection handles BeginDbTransaction internally via CapturingTransaction. + var capture = new CapturingConnection(executedSqls); + + var mockConnectionProvider = new Mock(); + mockConnectionProvider.Setup(p => p.Create()).Returns(capture); + + var mockUoW = new Mock(); + mockUoW.Setup(u => u.Connection).Returns((DbConnection)null); + + var mockQueryFactory = BuildQueryFactory(dbType, cfg); + + var repo = new WorkflowRepository( + mockConnectionProvider.Object, + cfg, + mockUoW.Object, + mockQueryFactory); + + // Act + await repo.DeleteWorkflowWithAllDependenciesAsync("wf-001"); + + // Assert – the very first executed SQL must be the lock statement + executedSqls.Should().NotBeEmpty("at least the lock + delete statements should have been executed"); + executedSqls[0].Should().Contain( + dbType == DatabaseTypes.Postgres ? "FOR UPDATE" : "WITH (UPDLOCK, HOLDLOCK)", + $"the FIRST SQL executed for {dbType} must be the lock SELECT, not a DELETE"); + } + finally + { + DataConfig.DatabaseType = originalDbType; + } + } + + /// + /// Verifies the lock SELECT appears before all four DELETE statements in the + /// standalone path. + /// + [Test] + [TestCase(DatabaseTypes.Postgres)] + [TestCase(DatabaseTypes.SqlServer)] + public async Task StandalonePath_AllDeletesFollowTheLockSelect(DatabaseTypes dbType) + { + var originalDbType = DataConfig.DatabaseType; + DataConfig.DatabaseType = dbType; + + try + { + var cfg = BuildConfig(dbType); + var executedSqls = new List(); + + var capture = new CapturingConnection(executedSqls); + + var mockConnectionProvider = new Mock(); + mockConnectionProvider.Setup(p => p.Create()).Returns(capture); + + var mockUoW = new Mock(); + mockUoW.Setup(u => u.Connection).Returns((DbConnection)null); + + var repo = new WorkflowRepository( + mockConnectionProvider.Object, + cfg, + mockUoW.Object, + BuildQueryFactory(dbType, cfg)); + + await repo.DeleteWorkflowWithAllDependenciesAsync("wf-002"); + + // Expect: lock, delete-run-logs, delete-runs, delete-steps, delete-workflow + executedSqls.Should().HaveCount(5, "exactly 5 statements: 1 lock + 4 deletes"); + + var lockKeyword = dbType == DatabaseTypes.Postgres ? "FOR UPDATE" : "WITH (UPDLOCK, HOLDLOCK)"; + executedSqls[0].Should().Contain(lockKeyword, "statement 0 must be the lock SELECT"); + executedSqls[1].Should().Contain("DELETE", "statement 1 must delete WorkflowRunLogs"); + executedSqls[2].Should().Contain("DELETE", "statement 2 must delete WorkflowRuns"); + executedSqls[3].Should().Contain("DELETE", "statement 3 must delete WorkflowSteps"); + executedSqls[4].Should().Contain("DELETE", "statement 4 must delete the Workflow parent row"); + } + finally + { + DataConfig.DatabaseType = originalDbType; + } + } + + // ── call-order tests (ambient UoW path) ─────────────────────────────────────── + + /// + /// Verifies that when an ambient UoW connection is present the lock SELECT is still + /// the first statement executed before any DELETE. + /// + [Test] + [TestCase(DatabaseTypes.Postgres)] + [TestCase(DatabaseTypes.SqlServer)] + public async Task AmbientUoWPath_LockSelectIsFirstStatementExecuted(DatabaseTypes dbType) + { + var originalDbType = DataConfig.DatabaseType; + DataConfig.DatabaseType = dbType; + + try + { + var cfg = BuildConfig(dbType); + var executedSqls = new List(); + + var capture = new CapturingConnection(executedSqls); + + // Simulate an active UoW connection so the method takes the UoW branch + var mockTx = new Mock(); + var mockUoW = new Mock(); + mockUoW.Setup(u => u.Connection).Returns(capture); + mockUoW.Setup(u => u.Transaction).Returns(mockTx.Object); + mockUoW.Setup(u => u.CreateOrGetConnection()).Returns(capture); + + var mockConnectionProvider = new Mock(); + + var repo = new WorkflowRepository( + mockConnectionProvider.Object, + cfg, + mockUoW.Object, + BuildQueryFactory(dbType, cfg)); + + await repo.DeleteWorkflowWithAllDependenciesAsync("wf-003"); + + executedSqls.Should().HaveCount(5, "exactly 5 statements: 1 lock + 4 deletes"); + + var lockKeyword = dbType == DatabaseTypes.Postgres ? "FOR UPDATE" : "WITH (UPDLOCK, HOLDLOCK)"; + executedSqls[0].Should().Contain(lockKeyword, + $"the FIRST SQL executed for {dbType} in the UoW path must be the lock SELECT"); + } + finally + { + DataConfig.DatabaseType = originalDbType; + } + } + + // ── concurrency simulation tests ─────────────────────────────────────────────── + + /// + /// Simulates a concurrent "inserter" that tries to INSERT a WorkflowRun while the + /// "deleter" holds the parent-row lock. The inserter must wait until the deleter + /// releases the lock (commits/rolls back). + /// + /// This test uses a to model the database lock: the + /// deleter acquires it on its lock-SELECT call and releases it only after all + /// DELETEs are done (simulating transaction commit). The inserter is blocked on + /// the semaphore and may only proceed afterwards, proving no FK violation window + /// exists. + /// + [Test] + [TestCase(DatabaseTypes.Postgres, Description = "PostgreSQL – FOR UPDATE blocks concurrent inserter")] + [TestCase(DatabaseTypes.SqlServer, Description = "SQL Server – UPDLOCK+HOLDLOCK blocks concurrent inserter")] + public async Task ConcurrentRunInsert_IsBlockedUntilDeleteTransactionCompletes(DatabaseTypes dbType) + { + var originalDbType = DataConfig.DatabaseType; + DataConfig.DatabaseType = dbType; + + try + { + const string workflowId = "wf-concurrent-001"; + + // The semaphore models the database row lock held by the deleter. + // Initial count 1 = lock is free; the deleter takes it, inserter waits. + var dbLock = new SemaphoreSlim(1, 1); + + // Timeline records: each side appends its action name so we can assert order. + var timeline = new List(); + + // ── Deleter setup ──────────────────────────────────────────────────────── + var deleteExecutedSqls = new List(); + var deletingConn = new CapturingConnection( + deleteExecutedSqls, + onLockSelected: () => + { + // Deleter acquires the "DB lock" when it issues the lock-SELECT + dbLock.Wait(); + timeline.Add("deleter:lock_acquired"); + }, + onAllDeleted: () => + { + // Deleter releases after finishing all DELETEs (transaction commit) + dbLock.Release(); + timeline.Add("deleter:lock_released"); + }); + + var cfg = BuildConfig(dbType); + + var mockUoWDeleter = new Mock(); + mockUoWDeleter.Setup(u => u.Connection).Returns((DbConnection)null); + + var mockConnProviderDeleter = new Mock(); + mockConnProviderDeleter.Setup(p => p.Create()).Returns(deletingConn); + + var repo = new WorkflowRepository( + mockConnProviderDeleter.Object, + cfg, + mockUoWDeleter.Object, + BuildQueryFactory(dbType, cfg)); + + // ── Inserter setup ─────────────────────────────────────────────────────── + // The inserter tries to INSERT a WorkflowRun for the same workflowId. + // It is blocked until dbLock is available. + var inserterTask = Task.Run(async () => + { + // Give the deleter a moment to start so it acquires the lock first + await Task.Delay(20); + + timeline.Add("inserter:waiting_for_lock"); + await dbLock.WaitAsync(); // blocked while deleter holds it + try + { + timeline.Add("inserter:lock_acquired"); + // Simulate an INSERT WorkflowRun – by this point the parent row is + // already gone, so in a real DB this would succeed or get a FK error + // depending on whether the row was committed-away. + // Here we just record the attempt came AFTER the deleter released. + } + finally + { + dbLock.Release(); + } + }); + + // ── Run deleter concurrently ───────────────────────────────────────────── + var deleterTask = repo.DeleteWorkflowWithAllDependenciesAsync(workflowId); + + await Task.WhenAll(deleterTask, inserterTask); + + // Assert timeline: inserter must never acquire the lock before deleter releases it + var lockAcquiredIdx = timeline.IndexOf("deleter:lock_acquired"); + var lockReleasedIdx = timeline.IndexOf("deleter:lock_released"); + var inserterWaitIdx = timeline.IndexOf("inserter:waiting_for_lock"); + var inserterAcqIdx = timeline.IndexOf("inserter:lock_acquired"); + + lockAcquiredIdx.Should().BeGreaterThanOrEqualTo(0, "deleter must acquire the lock"); + lockReleasedIdx.Should().BeGreaterThan(lockAcquiredIdx, "deleter must release after acquiring"); + inserterAcqIdx.Should().BeGreaterThan(lockReleasedIdx, + "inserter must only acquire the lock AFTER the deleter releases it (no FK window)"); + inserterWaitIdx.Should().BeLessThan(inserterAcqIdx, + "inserter must have been waiting before it acquired the lock"); + } + finally + { + DataConfig.DatabaseType = originalDbType; + } + } + + // ── helpers ───────────────────────────────────────────────────────────────────── + + /// + /// Builds a minimal mock whose GetDeleteQuery returns + /// a valid (non-null, non-empty) SQL string for each of the three child-delete + /// query types used by . + /// + private static IQueryFactory BuildQueryFactory(DatabaseTypes dbType, SqlConfiguration cfg) + { + var mock = new Mock(); + + // WorkflowRunLogs delete + mock.Setup(f => f.GetDeleteQuery()) + .Returns(dbType == DatabaseTypes.Postgres + ? $"DELETE FROM {cfg.SchemaName}.workflowrunlogs WHERE workflowrunid IN (SELECT workflowrunid FROM {cfg.SchemaName}.workflowruns WHERE workflowid = @WorkflowId)" + : $"DELETE FROM {cfg.SchemaName}.[WorkflowRunLogs] WHERE [WorkflowRunId] IN (SELECT [WorkflowRunId] FROM {cfg.SchemaName}.[WorkflowRuns] WHERE [WorkflowId] = @WorkflowId)"); + + // WorkflowRuns delete + mock.Setup(f => f.GetDeleteQuery()) + .Returns(dbType == DatabaseTypes.Postgres + ? $"DELETE FROM {cfg.SchemaName}.workflowruns WHERE workflowid = @WorkflowId" + : $"DELETE FROM {cfg.SchemaName}.[WorkflowRuns] WHERE [WorkflowId] = @WorkflowId"); + + // WorkflowSteps delete + mock.Setup(f => f.GetDeleteQuery()) + .Returns(dbType == DatabaseTypes.Postgres + ? $"DELETE FROM {cfg.SchemaName}.workflowsteps WHERE workflowid = @WorkflowId" + : $"DELETE FROM {cfg.SchemaName}.[WorkflowSteps] WHERE [WorkflowId] = @WorkflowId"); + + return mock.Object; + } + } + + // ── CapturingConnection ────────────────────────────────────────────────────────── + + /// + /// A thin fake that records each SQL string passed to + /// Dapper's ExecuteAsync (which ultimately calls + /// ). + /// + /// Optional lock-acquired and all-deleted callbacks let the concurrency test inject timing signals. + /// + internal sealed class CapturingConnection : DbConnection + { + private readonly List _log; + private readonly Action _onLockSelected; + private readonly Action _onAllDeleted; + + // Counts how many DELETE statements have been executed (we expect 4). + private int _deleteCount; + + public CapturingConnection( + List log, + Action onLockSelected = null, + Action onAllDeleted = null) + { + _log = log; + _onLockSelected = onLockSelected; + _onAllDeleted = onAllDeleted; + } + + // ── DbConnection overrides ─────────────────────────────────────────────────── + + public override string ConnectionString { get; set; } = "Capture://"; + public override string Database => "capture"; + public override string DataSource => "capture"; + public override string ServerVersion => "0"; + public override ConnectionState State => ConnectionState.Open; + + public override void Open() { } + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) + => new CapturingTransaction(this, isolationLevel, OnCommit); + + public override void ChangeDatabase(string databaseName) { } + public override void Close() { } + + protected override DbCommand CreateDbCommand() => new CapturingCommand(this, OnExecute); + + // ── internal callback ──────────────────────────────────────────────────────── + + private void OnExecute(string sql) + { + _log.Add(sql ?? string.Empty); + + // If the SQL is a lock SELECT fire the callback so the concurrency test can + // simulate the DB acquiring a row lock. + if (!string.IsNullOrEmpty(sql) && + (sql.Contains("FOR UPDATE", StringComparison.OrdinalIgnoreCase) || + sql.Contains("UPDLOCK", StringComparison.OrdinalIgnoreCase))) + { + _onLockSelected?.Invoke(); + } + + if (!string.IsNullOrEmpty(sql) && + sql.TrimStart().StartsWith("DELETE", StringComparison.OrdinalIgnoreCase)) + { + _deleteCount++; + if (_deleteCount == 4) + _onAllDeleted?.Invoke(); + } + } + + private void OnCommit() + { + // The transaction committed – nothing extra needed for the capture, but the + // concurrency test's onAllDeletesDone callback may already have fired. + } + } + + /// Minimal DbTransaction that calls an optional commit callback. + internal sealed class CapturingTransaction : DbTransaction + { + private readonly DbConnection _conn; + private readonly Action _onCommit; + + public CapturingTransaction(DbConnection conn, IsolationLevel isolation, Action onCommit) + { + _conn = conn; + IsolationLevel = isolation; + _onCommit = onCommit; + } + + public override IsolationLevel IsolationLevel { get; } + protected override DbConnection DbConnection => _conn; + + public override void Commit() => _onCommit?.Invoke(); + public override void Rollback() { } + } + + /// Minimal DbCommand that captures CommandText on ExecuteNonQuery. + internal sealed class CapturingCommand : DbCommand + { + private readonly Action _onExecute; + private readonly CapturingConnection _conn; + + public CapturingCommand(CapturingConnection conn, Action onExecute) + { + _conn = conn; + _onExecute = onExecute; + } + + public override string CommandText { get; set; } = string.Empty; + public override int CommandTimeout { get; set; } + public override CommandType CommandType { get; set; } + public override bool DesignTimeVisible { get; set; } + public override UpdateRowSource UpdatedRowSource { get; set; } + protected override DbConnection DbConnection { get => _conn; set { } } + protected override DbParameterCollection DbParameterCollection { get; } = new CapturingParameterCollection(); + protected override DbTransaction DbTransaction { get; set; } + + public override void Cancel() { } + public override int ExecuteNonQuery() + { + _onExecute(CommandText); + return 0; + } + public override object ExecuteScalar() + { + _onExecute(CommandText); + return null; + } + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) + { + _onExecute(CommandText); + return new CapturingDataReader(); + } + public override void Prepare() { } + protected override DbParameter CreateDbParameter() => new CapturingParameter(); + } + + /// Empty DbDataReader used when Dapper calls ExecuteReader on the lock SELECT. + internal sealed class CapturingDataReader : DbDataReader + { + public override bool Read() => false; + public override bool NextResult() => false; + public override void Close() { } + public override bool HasRows => false; + public override bool IsClosed => true; + public override int RecordsAffected => 0; + public override int FieldCount => 0; + public override int Depth => 0; + public override object this[int ordinal] => throw new IndexOutOfRangeException(); + public override object this[string name] => throw new KeyNotFoundException(); + public override bool GetBoolean(int ordinal) => false; + public override byte GetByte(int ordinal) => 0; + public override long GetBytes(int ordinal, long dataOffset, byte[] buffer, int bufferOffset, int length) => 0; + public override char GetChar(int ordinal) => default; + public override long GetChars(int ordinal, long dataOffset, char[] buffer, int bufferOffset, int length) => 0; + public override string GetDataTypeName(int ordinal) => "object"; + public override DateTime GetDateTime(int ordinal) => default; + public override decimal GetDecimal(int ordinal) => 0; + public override double GetDouble(int ordinal) => 0; + public override Type GetFieldType(int ordinal) => typeof(object); + public override float GetFloat(int ordinal) => 0; + public override Guid GetGuid(int ordinal) => default; + public override short GetInt16(int ordinal) => 0; + public override int GetInt32(int ordinal) => 0; + public override long GetInt64(int ordinal) => 0; + public override string GetName(int ordinal) => string.Empty; + public override int GetOrdinal(string name) => 0; + public override string GetString(int ordinal) => string.Empty; + public override object GetValue(int ordinal) => DBNull.Value; + public override int GetValues(object[] values) => 0; + public override bool IsDBNull(int ordinal) => true; + public override IEnumerator GetEnumerator() => ((IEnumerable)Array.Empty()).GetEnumerator(); + } + + /// Minimal DbParameter. + internal sealed class CapturingParameter : DbParameter + { + public override DbType DbType { get; set; } + public override ParameterDirection Direction { get; set; } + public override bool IsNullable { get; set; } + public override string ParameterName { get; set; } = string.Empty; + public override int Size { get; set; } + public override string SourceColumn { get; set; } = string.Empty; + public override bool SourceColumnNullMapping { get; set; } + public override object Value { get; set; } + public override void ResetDbType() { } + } + + /// Minimal DbParameterCollection. + internal sealed class CapturingParameterCollection : DbParameterCollection + { + private readonly List _items = new(); + + public override int Count => _items.Count; + public override object SyncRoot => this; + public override bool IsFixedSize => false; + public override bool IsReadOnly => false; + public override bool IsSynchronized => false; + + public override int Add(object value) { _items.Add((DbParameter)value); return _items.Count - 1; } + public override void AddRange(Array values) { foreach (var v in values) _items.Add((DbParameter)v); } + public override void Clear() => _items.Clear(); + public override bool Contains(object value) => _items.Contains((DbParameter)value); + public override bool Contains(string value) => _items.Exists(p => p.ParameterName == value); + public override void CopyTo(Array array, int index) => ((System.Collections.ICollection)_items).CopyTo(array, index); + public override System.Collections.IEnumerator GetEnumerator() => _items.GetEnumerator(); + public override int IndexOf(object value) => _items.IndexOf((DbParameter)value); + public override int IndexOf(string parameterName) => _items.FindIndex(p => p.ParameterName == parameterName); + public override void Insert(int index, object value) => _items.Insert(index, (DbParameter)value); + public override void Remove(object value) => _items.Remove((DbParameter)value); + public override void RemoveAt(int index) => _items.RemoveAt(index); + public override void RemoveAt(string parameterName) => _items.RemoveAt(IndexOf(parameterName)); + protected override DbParameter GetParameter(int index) => _items[index]; + protected override DbParameter GetParameter(string parameterName) => _items[IndexOf(parameterName)]; + protected override void SetParameter(int index, DbParameter value) => _items[index] = value; + protected override void SetParameter(string parameterName, DbParameter value) => _items[IndexOf(parameterName)] = value; + } +} + + + + + + + + + + + + +