From 025b6db3f24a9392e1d4312b3d9ae12c033cad2d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 10 Aug 2026 13:48:18 -0700 Subject: [PATCH 1/6] RG-T117 IC Chat fixes --- .claude/settings.local.json | 18 +- Core/Resgrid.Model/Chat/ChatEnums.cs | 9 +- .../IncidentCommand/ResourceIncidentView.cs | 41 ++++ .../Repositories/IChatRepositories.cs | 3 + Core/Resgrid.Model/Services/IChatServices.cs | 23 +++ Core/Resgrid.Services/ChatChannelService.cs | 105 +++++++++- Core/Resgrid.Services/ChatMessageService.cs | 34 ++++ .../Resgrid.Services/ChatPermissionService.cs | 59 ++++++ .../ChatProvisioningEventService.cs | 74 ++++++- .../IncidentCommandService.cs | 87 ++++++++ .../ChatRepositories.cs | 33 +++ .../Services/ChatFrozenChannelTests.cs | 156 +++++++++++++++ .../Services/ChatIncidentBackfillTests.cs | 189 ++++++++++++++++++ .../Services/ChatPermissionServiceTests.cs | 127 ++++++++++++ .../Controllers/v4/ChatController.cs | 5 +- .../Resgrid.Web.Services.xml | 3 +- 16 files changed, 952 insertions(+), 14 deletions(-) create mode 100644 Tests/Resgrid.Tests/Services/ChatFrozenChannelTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0bf8d36cc..cea54caec 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -24,7 +24,12 @@ "Bash(brew --prefix dotnet)", "Bash(/opt/homebrew/opt/dotnet/bin/dotnet build:*)", "Bash(brew info:*)", - "mcp__graperoot-pro__graph_register_edit" + "mcp__graperoot-pro__graph_register_edit", + "mcp__graperoot-pro__graph_grep_all", + "Bash(/usr/local/share/dotnet/dotnet build *)", + "Bash(awk 'NR>=94 && /HttpGet\\\\\\(\"IncomingMessage\"\\\\\\)/{f=1} f{print NR\": \"$0} f && /^\\\\t\\\\t\\\\}$/{c++; if\\(c==1\\) exit}')", + "Bash(dotnet test *)", + "Bash(git -C /Volumes/USBSSD/dev/Resgrid/Core log --oneline -1 -- Providers/Resgrid.Providers.Migrations/Migrations/M0094_AddIncidentCommandNameAndLocations.cs)" ] }, "enableAllProjectMcpServers": true, @@ -53,6 +58,17 @@ } ] } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "/Users/shawn/.graperoot-pro/venv/bin/python3 \"/Users/shawn/.graperoot-pro/stop_hook.py\"" + } + ] + } ] } } diff --git a/Core/Resgrid.Model/Chat/ChatEnums.cs b/Core/Resgrid.Model/Chat/ChatEnums.cs index cd6436bb8..0f01761b2 100644 --- a/Core/Resgrid.Model/Chat/ChatEnums.cs +++ b/Core/Resgrid.Model/Chat/ChatEnums.cs @@ -11,7 +11,14 @@ public enum ChatChannelType Incident = 5, IncidentLane = 6, IncidentCommand = 7, - Chatbot = 8 + Chatbot = 8, + + /// + /// Incident Commander plus every lane's primary and secondary lead — command talking to the + /// people running the lanes, without the lane crews. Membership is derived live from the lanes, + /// so demoting a lead removes their access on the next check. + /// + IncidentLeads = 9 } /// Who a chat participant is: a person, a unit-shared identity ("Engine 6"), or the chatbot. diff --git a/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs b/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs index 368e0e158..517d1d2b5 100644 --- a/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs +++ b/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs @@ -45,6 +45,47 @@ public class ResourceIncidentView /// The caller's active lane assignment, when they have one (null otherwise). public ResourceLaneAssignmentView MyAssignment { get; set; } + + /// + /// ICS positions filled on this incident, with contact details, so a responder can reach the right + /// person directly instead of going through command. Empty when nobody holds a position. + /// + public List Roles { get; set; } = new List(); + + /// + /// Chat channels the CALLER can actually reach, resolved server-side so clients never have to + /// guess at access. Null means "not available to you" — the caller is not command staff, holds no + /// lane lead slot, or the channel has not been provisioned. + /// + public IncidentChatChannels Chat { get; set; } = new IncidentChatChannels(); + } + + /// Who holds an ICS position on the incident, with the contact details to reach them. + public class IncidentRoleContactInfo + { + /// Maps to . + public int RoleType { get; set; } + + public IncidentContactInfo Contact { get; set; } + } + + /// The incident's chat channels, filtered to the ones the caller may open. + public class IncidentChatChannels + { + /// The call-wide incident channel (everyone on the call). + public string IncidentChannelId { get; set; } + + /// The private command channel — only set for command staff (IC or an ICS role holder). + public string CommandChannelId { get; set; } + + /// The "All Leads" channel — only set for the IC and lane primary/secondary leads. + public string LeadsChannelId { get; set; } + + /// The caller's own lane channel, when they are assigned to a lane. + public string LaneChannelId { get; set; } + + /// True once the incident is closed: the conversations are readable but frozen. + public bool IsFrozen { get; set; } } /// Contact card for a person relevant to a resource (commander or lane lead). diff --git a/Core/Resgrid.Model/Repositories/IChatRepositories.cs b/Core/Resgrid.Model/Repositories/IChatRepositories.cs index 2070a184b..a47d3614a 100644 --- a/Core/Resgrid.Model/Repositories/IChatRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IChatRepositories.cs @@ -35,6 +35,9 @@ public interface IChatChannelRepository : IRepository /// Archives (or unarchives) every channel anchored to a call; returns affected channel ids. Task> SetArchivedByCallIdAsync(int callId, bool archived, DateTime? archivedOn); + /// Archives/unarchives every channel anchored to one incident command (the command channel and its lane channels), returning the affected channel ids. + Task> SetArchivedByIncidentCommandIdAsync(string incidentCommandId, bool archived, DateTime? archivedOn); + /// Channels in the department carrying a per-channel retention override. Task> GetWithRetentionOverrideAsync(int departmentId); diff --git a/Core/Resgrid.Model/Services/IChatServices.cs b/Core/Resgrid.Model/Services/IChatServices.cs index 09c2eaed1..1a4c43aef 100644 --- a/Core/Resgrid.Model/Services/IChatServices.cs +++ b/Core/Resgrid.Model/Services/IChatServices.cs @@ -103,12 +103,35 @@ public interface IChatChannelService Task EnsureCommandChannelAsync(IncidentCommand command, CancellationToken cancellationToken = default(CancellationToken)); + /// Ensures the incident's "All Leads" channel: the IC and every lane's primary/secondary lead. + Task EnsureLeadsChannelAsync(IncidentCommand command, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Backfills every chat channel an ACTIVE incident should have — the call's incident channel, the + /// command and "All Leads" channels, and one per live lane — inserting only what is missing. + /// + /// Exists for incidents that were established before those channels were a thing: rather than a + /// one-off migration, the read paths call this and the incident heals itself the first time someone + /// opens it. Idempotent, and guarded by a short-lived marker so a board that refreshes on a timer + /// pays one cache read instead of a channel query. Closed commands are skipped — provisioning a + /// channel there would create it unarchived and quietly un-freeze a point-in-time record. + /// + Task EnsureIncidentChannelsAsync(IncidentCommand command, IEnumerable nodes, CancellationToken cancellationToken = default(CancellationToken)); + /// Provisions the per-user chatbot channel; only call when a chatbot session starts (never on the channel-list path). Task EnsureChatbotChannelAsync(int departmentId, string userId, CancellationToken cancellationToken = default(CancellationToken)); /// Archives every channel anchored to a call (call closed); unarchive on reopen. Task SetIncidentChannelsArchivedAsync(int callId, bool archived, CancellationToken cancellationToken = default(CancellationToken)); + /// + /// Archives every channel anchored to ONE incident command — its command channel and its lane + /// channels — leaving the call's own incident channel alone. Used when command is closed while the + /// call itself keeps running: the command conversation becomes a point-in-time record while the + /// call channel stays live. Unarchive on reopen. + /// + Task SetCommandChannelsArchivedAsync(string incidentCommandId, bool archived, CancellationToken cancellationToken = default(CancellationToken)); + /// Department chat settings (config defaults when no row exists); no authorization — safe for any department-scoped caller. Task GetDepartmentSettingsAsync(int departmentId); diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs index abb37c96c..7d84d0ab8 100644 --- a/Core/Resgrid.Services/ChatChannelService.cs +++ b/Core/Resgrid.Services/ChatChannelService.cs @@ -23,6 +23,9 @@ public class ChatChannelService : IChatChannelService { private static readonly TimeSpan ChannelListCacheLength = TimeSpan.FromSeconds(45); + /// How long a completed incident-channel backfill suppresses the next sweep for that command. + private static readonly TimeSpan IncidentBackfillCacheLength = TimeSpan.FromMinutes(30); + private readonly IChatChannelRepository _chatChannelRepository; private readonly IChatChannelMemberRepository _chatChannelMemberRepository; private readonly IChatChannelAccessRuleRepository _chatChannelAccessRuleRepository; @@ -703,6 +706,87 @@ public async Task GetUserMembershipAsync(string chatChannelId }, () => _chatChannelRepository.GetByCallIdAndTypeAsync(command.CallId, (int)ChatChannelType.IncidentCommand), cancellationToken); } + public async Task EnsureLeadsChannelAsync(IncidentCommand command, CancellationToken cancellationToken = default(CancellationToken)) + { + if (command == null) + return null; + + var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(command.CallId, (int)ChatChannelType.IncidentLeads); + if (existing != null) + return existing; + + return await InsertProvisionedChannelAsync(new ChatChannel + { + ChatChannelId = Guid.NewGuid().ToString(), + DepartmentId = command.DepartmentId, + ChannelType = (int)ChatChannelType.IncidentLeads, + Name = "All Leads", + CallId = command.CallId, + IncidentCommandId = command.IncidentCommandId, + CreatedOn = DateTime.UtcNow + }, () => _chatChannelRepository.GetByCallIdAndTypeAsync(command.CallId, (int)ChatChannelType.IncidentLeads), cancellationToken); + } + + public async Task EnsureIncidentChannelsAsync(IncidentCommand command, IEnumerable nodes, CancellationToken cancellationToken = default(CancellationToken)) + { + if (command == null || command.CallId <= 0) + return; + + // A closed command's channels are a frozen record. Anything created now would be unarchived, + // so the freeze would silently lift for a channel nobody ever posted in. + if (command.Status != (int)IncidentCommandStatus.Active) + return; + + var markerKey = $"chat:incidentbackfill:{command.IncidentCommandId}"; + + try + { + if (!string.IsNullOrEmpty(await _cacheProvider.GetStringAsync(markerKey))) + return; + } + catch (Exception ex) + { + // A cache outage must not stop the backfill — worst case it runs again on the next read. + Logging.LogException(ex); + } + + try + { + // One read of the call's channels covers every check below, instead of a lookup per Ensure*. + var existing = (await _chatChannelRepository.GetByCallIdAsync(command.CallId))?.ToList() ?? new List(); + + if (!existing.Any(c => c.ChannelType == (int)ChatChannelType.Incident)) + await EnsureIncidentChannelAsync(command.DepartmentId, command.CallId, null, cancellationToken); + + if (!existing.Any(c => c.ChannelType == (int)ChatChannelType.IncidentCommand)) + await EnsureCommandChannelAsync(command, cancellationToken); + + if (!existing.Any(c => c.ChannelType == (int)ChatChannelType.IncidentLeads)) + await EnsureLeadsChannelAsync(command, cancellationToken); + + var provisionedNodeIds = new HashSet( + existing.Where(c => c.ChannelType == (int)ChatChannelType.IncidentLane && !string.IsNullOrWhiteSpace(c.CommandStructureNodeId)) + .Select(c => c.CommandStructureNodeId), + StringComparer.OrdinalIgnoreCase); + + var missingLanes = (nodes ?? Enumerable.Empty()) + .Where(n => n != null && !n.DeletedOn.HasValue && !provisionedNodeIds.Contains(n.CommandStructureNodeId)) + .ToList(); + + // Serialized deliberately: these share the caller's unit-of-work connection, which is not + // concurrency-safe. Bounded by the lane count on a once-per-incident path. + foreach (var node in missingLanes) + await EnsureLaneChannelAsync(node, cancellationToken); + + await _cacheProvider.SetStringAsync(markerKey, "1", IncidentBackfillCacheLength); + } + catch (Exception ex) + { + // Best-effort: chat provisioning must never cost the caller their board or incident view. + Logging.LogException(ex); + } + } + public async Task EnsureChatbotChannelAsync(int departmentId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var existing = await _chatChannelRepository.GetChatbotChannelAsync(departmentId, userId); @@ -743,7 +827,26 @@ await _chatChannelMemberRepository.InsertAsync(new ChatChannelMember public async Task SetIncidentChannelsArchivedAsync(int callId, bool archived, CancellationToken cancellationToken = default(CancellationToken)) { var affected = await _chatChannelRepository.SetArchivedByCallIdAsync(callId, archived, archived ? DateTime.UtcNow : (DateTime?)null); - var affectedList = affected?.ToList() ?? new List(); + return await PublishArchiveChangeAsync(affected); + } + + public async Task SetCommandChannelsArchivedAsync(string incidentCommandId, bool archived, CancellationToken cancellationToken = default(CancellationToken)) + { + if (string.IsNullOrWhiteSpace(incidentCommandId)) + return false; + + var affected = await _chatChannelRepository.SetArchivedByIncidentCommandIdAsync(incidentCommandId, archived, archived ? DateTime.UtcNow : (DateTime?)null); + return await PublishArchiveChangeAsync(affected); + } + + /// + /// Drops the cached permission evaluations for every channel whose archived flag just moved and + /// tells connected clients to re-read it — a frozen channel has to stop accepting posts on every + /// device immediately, not whenever the cache happens to expire. + /// + private async Task PublishArchiveChangeAsync(IEnumerable affectedChannelIds) + { + var affectedList = affectedChannelIds?.ToList() ?? new List(); foreach (var channelId in affectedList) await _chatPermissionService.InvalidateChannelCacheAsync(channelId); diff --git a/Core/Resgrid.Services/ChatMessageService.cs b/Core/Resgrid.Services/ChatMessageService.cs index 592d901b6..2dbb82c17 100644 --- a/Core/Resgrid.Services/ChatMessageService.cs +++ b/Core/Resgrid.Services/ChatMessageService.cs @@ -261,12 +261,34 @@ public async Task> GetThreadPageAsync(string threadRootMessage return messages?.ToList() ?? new List(); } + /// + /// True when the channel is archived, i.e. frozen as a point-in-time record: a closed incident + /// command's channel and its lane channels, or a closed call's channel. Posting is already blocked + /// by IChatPermissionService.CanPostAsync; this is the matching gate for mutating what is + /// already there. Moderation (flagging, moderator delete) deliberately does NOT consult it. + /// A missing channel reads as frozen — fail closed rather than allow an unanchored edit. + /// + private async Task IsChannelFrozenAsync(string chatChannelId) + { + if (string.IsNullOrWhiteSpace(chatChannelId)) + return true; + + var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); + return channel == null || channel.IsArchived; + } + public async Task EditMessageAsync(string chatMessageId, string editorUserId, string newBody, CancellationToken cancellationToken = default(CancellationToken)) { var message = await _chatMessageRepository.GetByIdAsync(chatMessageId); if (message == null || message.DeletedOn.HasValue) return null; + // An archived channel is a point-in-time record (a closed incident command/lane chat, a closed + // call). CanPostAsync already refuses new messages there; the history has to be just as + // immutable, or the record could still be rewritten after the fact. + if (await IsChannelFrozenAsync(message.ChatChannelId)) + return null; + if (!string.Equals(message.SenderUserId, editorUserId, StringComparison.OrdinalIgnoreCase)) return null; @@ -301,6 +323,12 @@ public async Task> GetThreadPageAsync(string threadRootMessage return false; var isModeratorDelete = asModerator && !isSender; + + // Frozen channel: the author can no longer retract what they said, but moderation still has to + // work — flagged content on a closed incident must remain removable. + if (!isModeratorDelete && await IsChannelFrozenAsync(message.ChatChannelId)) + return false; + await SaveEditHistoryAsync(message, isModeratorDelete ? ChatMessageEditType.ModeratorDelete : ChatMessageEditType.SenderDelete, byUserId, cancellationToken); var deletedOn = DateTime.UtcNow; @@ -336,6 +364,9 @@ public async Task> GetThreadPageAsync(string threadRootMessage if (message == null || message.DeletedOn.HasValue) return false; + if (await IsChannelFrozenAsync(message.ChatChannelId)) + return false; + // Banned or currently-muted participants can't react; silently skip. var member = unitId.HasValue ? await _chatChannelMemberRepository.GetUnitMemberAsync(message.ChatChannelId, unitId.Value) @@ -389,6 +420,9 @@ await _chatMessageReactionRepository.InsertAsync(new ChatMessageReaction if (message == null) return false; + if (await IsChannelFrozenAsync(message.ChatChannelId)) + return false; + var participantType = unitId.HasValue ? (int)ChatParticipantType.Unit : (int)ChatParticipantType.User; var removed = await _chatMessageReactionRepository.DeleteReactionAsync(chatMessageId, participantType, unitId.HasValue ? null : userId, unitId, emoji, cancellationToken); diff --git a/Core/Resgrid.Services/ChatPermissionService.cs b/Core/Resgrid.Services/ChatPermissionService.cs index 4a22a1bcd..8afc31900 100644 --- a/Core/Resgrid.Services/ChatPermissionService.cs +++ b/Core/Resgrid.Services/ChatPermissionService.cs @@ -203,6 +203,10 @@ public async Task> ResolveChannelAudienceUserIdsAsync(ChatChannel c await AddCommandStaffAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault(), userIds); break; + case ChatChannelType.IncidentLeads: + await AddLaneLeadsAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault(), userIds); + break; + default: // DirectMessage, AdHocGroup await AddExplicitMemberAudienceAsync(channel, userIds); break; @@ -273,6 +277,12 @@ private async Task EvaluateAccessAsync(ChatChannel channel, string userId, return await IsCommandStaffAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault(), userId); + case ChatChannelType.IncidentLeads: + if (await IsDepartmentAdminAsync(channel.DepartmentId, userId)) + return true; + + return await IsLaneLeadOrCommanderAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault(), userId); + default: return false; } @@ -300,6 +310,7 @@ private async Task EvaluateModerateAsync(ChatChannel channel, string userI case ChatChannelType.Incident: case ChatChannelType.IncidentLane: case ChatChannelType.IncidentCommand: + case ChatChannelType.IncidentLeads: if (!channel.CallId.HasValue) return false; @@ -492,6 +503,54 @@ private async Task IsInLaneAudienceAsync(ChatChannel channel, string userI return false; } + /// + /// "All Leads" audience: the Incident Commander plus every lane's primary and secondary lead. + /// Deliberately derived from the lanes on each check rather than stored as membership — a lead who + /// is replaced on the board loses the channel without anyone having to remember to remove them. + /// + private async Task IsLaneLeadOrCommanderAsync(int departmentId, int callId, string userId) + { + if (callId <= 0) + return false; + + var command = await _incidentCommandService.GetCommandForCallAsync(departmentId, callId); + if (command != null && + (string.Equals(command.CurrentCommanderUserId, userId, StringComparison.OrdinalIgnoreCase) || + string.Equals(command.EstablishedByUserId, userId, StringComparison.OrdinalIgnoreCase))) + return true; + + var nodes = await _incidentCommandService.GetNodesForCallAsync(departmentId, callId); + if (nodes == null) + return false; + + return nodes.Any(n => !n.DeletedOn.HasValue && + (string.Equals(n.PrimaryLeadUserId, userId, StringComparison.OrdinalIgnoreCase) || + string.Equals(n.SecondaryLeadUserId, userId, StringComparison.OrdinalIgnoreCase))); + } + + private async Task AddLaneLeadsAsync(int departmentId, int callId, HashSet userIds) + { + if (callId <= 0) + return; + + var command = await _incidentCommandService.GetCommandForCallAsync(departmentId, callId); + if (command != null) + { + AddIfSet(userIds, command.CurrentCommanderUserId); + AddIfSet(userIds, command.EstablishedByUserId); + } + + var nodes = await _incidentCommandService.GetNodesForCallAsync(departmentId, callId); + if (nodes == null) + return; + + foreach (var node in nodes.Where(n => !n.DeletedOn.HasValue)) + { + AddIfSet(userIds, node.PrimaryLeadUserId); + AddIfSet(userIds, node.SecondaryLeadUserId); + } + } + private async Task IsCommandStaffAsync(int departmentId, int callId, string userId) { if (callId <= 0) diff --git a/Core/Resgrid.Services/ChatProvisioningEventService.cs b/Core/Resgrid.Services/ChatProvisioningEventService.cs index 6d9e6fe2a..a6e05c197 100644 --- a/Core/Resgrid.Services/ChatProvisioningEventService.cs +++ b/Core/Resgrid.Services/ChatProvisioningEventService.cs @@ -2,8 +2,10 @@ using System.Threading.Tasks; using Autofac; using Resgrid.Framework; +using Resgrid.Model; using Resgrid.Model.Events; using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; using Resgrid.Model.Services; namespace Resgrid.Services @@ -32,6 +34,8 @@ public ChatProvisioningEventService(IEventAggregator eventAggregator, ILifetimeS _eventAggregator.AddAsyncListener(OnCallAddedAsync); _eventAggregator.AddAsyncListener(OnCallClosedAsync); _eventAggregator.AddAsyncListener(OnCommandEstablishedAsync); + _eventAggregator.AddAsyncListener(OnIncidentClosedAsync); + _eventAggregator.AddAsyncListener(OnLaneLeadChangedAsync); _eventAggregator.AddAsyncListener(OnIncidentReopenedAsync); } @@ -67,23 +71,77 @@ private Task OnCommandEstablishedAsync(CommandEstablishedEvent message) if (command == null) return; - await chatChannelService.EnsureIncidentChannelAsync(message.DepartmentId, message.CallId, null); - await chatChannelService.EnsureCommandChannelAsync(command); - - // Lane channels for template-seeded nodes; later ad-hoc lanes are handled by SaveNodeAsync. - // Batched: one existing-channel read for the call, then insert only the missing lanes. + // Same entry point the read-path backfill uses, so establish and heal-on-read can never + // drift apart. One existing-channel read, then only the missing rows are inserted. + // Template-seeded lanes are covered here; later ad-hoc lanes come via SaveNodeAsync. var nodes = await incidentCommandService.GetNodesForCallAsync(message.DepartmentId, message.CallId); - await chatChannelService.EnsureLaneChannelsAsync(nodes); + await chatChannelService.EnsureIncidentChannelsAsync(command, nodes); }); } - private Task OnIncidentReopenedAsync(IncidentReopenedEvent message) + /// + /// A lane lead changed hands, so who can see the lane and "All Leads" channels changed with it. + /// Both audiences are derived live from the board, but the permission service caches its verdicts — + /// without this the outgoing lead keeps access, and the incoming one is locked out, until the cache + /// expires on its own. + /// + private Task OnLaneLeadChangedAsync(LaneLeadChangedEvent message) { if (message == null) return Task.CompletedTask; + return RunAsync(async scope => + { + var channelRepository = scope.Resolve(); + var permissionService = scope.Resolve(); + + var leadsChannel = await channelRepository.GetByCallIdAndTypeAsync(message.CallId, (int)ChatChannelType.IncidentLeads); + if (leadsChannel != null) + await permissionService.InvalidateChannelCacheAsync(leadsChannel.ChatChannelId); + + if (!string.IsNullOrWhiteSpace(message.CommandStructureNodeId)) + { + var laneChannel = await channelRepository.GetByCommandStructureNodeIdAsync(message.CommandStructureNodeId); + if (laneChannel != null) + await permissionService.InvalidateChannelCacheAsync(laneChannel.ChatChannelId); + } + }); + } + + /// + /// Command closed: freeze its command and lane channels into a point-in-time record. Scoped to the + /// command, NOT the call — the call may still be running, and its own incident channel has to stay + /// live. (The call-level freeze is CallClosedEvent's job.) + /// + private Task OnIncidentClosedAsync(IncidentClosedEvent message) + { + if (message == null || string.IsNullOrWhiteSpace(message.IncidentCommandId)) + return Task.CompletedTask; + return RunAsync(scope => scope.Resolve() - .SetIncidentChannelsArchivedAsync(message.CallId, false)); + .SetCommandChannelsArchivedAsync(message.IncidentCommandId, true)); + } + + private Task OnIncidentReopenedAsync(IncidentReopenedEvent message) + { + if (message == null) + return Task.CompletedTask; + + return RunAsync(async scope => + { + var chatChannelService = scope.Resolve(); + + // Thaw the reopened command's own channels first — this is the part that must happen even + // when the underlying call is closed. + if (!string.IsNullOrWhiteSpace(message.IncidentCommandId)) + await chatChannelService.SetCommandChannelsArchivedAsync(message.IncidentCommandId, false); + + // The call's incident channel only comes back when the call itself is open again; reopening + // command on a closed call must not resurrect the call-wide conversation. + var call = await scope.Resolve().GetCallByIdAsync(message.CallId); + if (call != null && !call.ClosedOn.HasValue) + await chatChannelService.SetIncidentChannelsArchivedAsync(message.CallId, false); + }); } /// diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index 60e3a8322..62a2fc2b7 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -506,6 +506,11 @@ public async Task GetCommandBoardAsync(int departmentId, i Maps = await GetIncidentMapsForCallAsync(departmentId, callId) }; + // Heal incidents established before the chat channels existed. Reuses the nodes already read + // for the board rather than querying them again, and the call is cache-guarded internally so + // this polled read does not re-sweep on every refresh. + await BackfillIncidentChatChannelsAsync(command, departmentId, callId, board.Nodes); + return board; } @@ -1031,9 +1036,91 @@ public async Task GetResourceIncidentViewAsync(int departm } } + await BackfillIncidentChatChannelsAsync(command, departmentId, callId); + await PopulateResourceViewContactsAndChatAsync(view, command, departmentId, callId, userId); + return view; } + /// + /// Heals incidents that pre-date the incident chat channels: the first time someone opens the + /// board or the responder view, any missing channel is created. Deliberately a lazy backfill on + /// the read paths rather than a migration, so nothing has to be swept over the whole estate — an + /// incident nobody looks at costs nothing. Best-effort and internally cache-guarded; never throws. + /// + private async Task BackfillIncidentChatChannelsAsync(IncidentCommand command, int departmentId, int callId, List knownNodes = null) + { + try + { + var nodes = knownNodes ?? await GetNodesForCallAsync(departmentId, callId); + await ServiceLocator.Current.GetInstance().EnsureIncidentChannelsAsync(command, nodes); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + } + + /// + /// Fills in who holds which ICS position and which chat channels this caller may open. Both are + /// resolved here rather than client-side so a responder app never has to infer access: a channel id + /// it does not receive is one it cannot open. + /// + private async Task PopulateResourceViewContactsAndChatAsync(ResourceIncidentView view, IncidentCommand command, int departmentId, int callId, string userId) + { + var nodes = await GetNodesForCallAsync(departmentId, callId) ?? new List(); + var roles = await GetIncidentRolesAsync(departmentId, callId) ?? new List(); + var activeRoles = roles.Where(r => !r.RemovedOn.HasValue).ToList(); + + foreach (var role in activeRoles.OrderBy(r => r.RoleType)) + { + var contact = await BuildUserContactAsync(role.UserId); + if (contact != null) + view.Roles.Add(new IncidentRoleContactInfo { RoleType = role.RoleType, Contact = contact }); + } + + var isCommander = string.Equals(command.CurrentCommanderUserId, userId, StringComparison.OrdinalIgnoreCase) + || string.Equals(command.EstablishedByUserId, userId, StringComparison.OrdinalIgnoreCase); + + var isCommandStaff = isCommander || activeRoles.Any(r => string.Equals(r.UserId, userId, StringComparison.OrdinalIgnoreCase)); + + var isLaneLead = nodes.Any(n => !n.DeletedOn.HasValue + && (string.Equals(n.PrimaryLeadUserId, userId, StringComparison.OrdinalIgnoreCase) + || string.Equals(n.SecondaryLeadUserId, userId, StringComparison.OrdinalIgnoreCase))); + + view.Chat.IsFrozen = command.Status != (int)IncidentCommandStatus.Active; + + try + { + // Resolved through the service locator, matching DeleteNodeAsync: the chat side depends on + // this service, so constructor-injecting it back would close a DI cycle. + var channels = (await ServiceLocator.Current.GetInstance() + .GetByCallIdAsync(callId))?.ToList() ?? new List(); + + view.Chat.IncidentChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.Incident)?.ChatChannelId; + + if (isCommandStaff) + view.Chat.CommandChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentCommand)?.ChatChannelId; + + if (isCommander || isLaneLead) + view.Chat.LeadsChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentLeads)?.ChatChannelId; + + var myNodeId = view.MyAssignment?.CommandStructureNodeId; + if (!string.IsNullOrWhiteSpace(myNodeId)) + { + view.Chat.LaneChannelId = channels + .FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentLane + && string.Equals(c.CommandStructureNodeId, myNodeId, StringComparison.OrdinalIgnoreCase))?.ChatChannelId; + } + } + catch (Exception ex) + { + // Chat is supplementary to the incident view — a lookup failure must not cost the responder + // their objectives, needs and lane assignment. + Resgrid.Framework.Logging.LogException(ex); + } + } + /// Contact card for a Resgrid user (name from the profile; phone/email as the profile exposes them). private async Task BuildUserContactAsync(string userId) { diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs index 1dab7e26a..5286f2781 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs @@ -340,6 +340,39 @@ public async Task> SetArchivedByCallIdAsync(int callId, bool } } + public async Task> SetArchivedByIncidentCommandIdAsync(string incidentCommandId, bool archived, DateTime? archivedOn) + { + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("IncidentCommandId", incidentCommandId); + parameters.Add("IsArchived", archived); + parameters.Add("ArchivedOn", archived ? archivedOn : (DateTime?)null, DbType.DateTime2); + parameters.Add("ModifiedOn", archivedOn ?? DateTime.UtcNow, DbType.DateTime2); + var notation = _sqlConfiguration.ParameterNotation; + var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres + ? $"UPDATE {_sqlConfiguration.SchemaName}.chatchannels SET isarchived = {notation}IsArchived, archivedon = {notation}ArchivedOn, modifiedon = {notation}ModifiedOn WHERE incidentcommandid = {notation}IncidentCommandId RETURNING chatchannelid" + : $"UPDATE {_sqlConfiguration.SchemaName}.[ChatChannels] SET [IsArchived] = {notation}IsArchived, [ArchivedOn] = {notation}ArchivedOn, [ModifiedOn] = {notation}ModifiedOn OUTPUT INSERTED.[ChatChannelId] WHERE [IncidentCommandId] = {notation}IncidentCommandId"; + + var select = new Func>>(connection => + connection.QueryAsync(sql, parameters, _unitOfWork.Transaction)); + + if (_unitOfWork?.Connection == null) + { + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await select(connection); + } + + return await select(_unitOfWork.CreateOrGetConnection()); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + public async Task> GetWithRetentionOverrideAsync(int departmentId) { try diff --git a/Tests/Resgrid.Tests/Services/ChatFrozenChannelTests.cs b/Tests/Resgrid.Tests/Services/ChatFrozenChannelTests.cs new file mode 100644 index 000000000..5ddcd93f2 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChatFrozenChannelTests.cs @@ -0,0 +1,156 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// A closed incident's command and lane chat becomes a point-in-time record: nobody posts, nobody + /// rewrites what is already there, and moderation still works. Posting is enforced by + /// ; this fixture covers the matching gates on the + /// mutation paths, which previously let an author keep editing history in an archived channel. + /// + [TestFixture] + public class ChatFrozenChannelTests + { + private const string ChannelId = "channel-1"; + private const string MessageId = "message-1"; + private const string SenderId = "sender"; + + private Mock _channelRepository; + private Mock _messageRepository; + private Mock _reactionRepository; + private Mock _editRepository; + private ChatMessage _message; + + [SetUp] + public void Setup() + { + _message = new ChatMessage + { + ChatMessageId = MessageId, + ChatChannelId = ChannelId, + DepartmentId = 1, + SenderUserId = SenderId, + Body = "original body" + }; + + _channelRepository = new Mock(); + _messageRepository = new Mock(); + _reactionRepository = new Mock(); + _editRepository = new Mock(); + + _messageRepository.Setup(x => x.GetByIdAsync(MessageId)).ReturnsAsync(_message); + _messageRepository + .Setup(x => x.UpdateBodyAsync(MessageId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _messageRepository + .Setup(x => x.TombstoneAsync(MessageId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + } + + private void GivenChannelArchived(bool archived) + { + _channelRepository + .Setup(x => x.GetByIdAsync(ChannelId)) + .ReturnsAsync(new ChatChannel { ChatChannelId = ChannelId, DepartmentId = 1, IsArchived = archived }); + } + + private ChatMessageService BuildService() + => new ChatMessageService( + _channelRepository.Object, + _messageRepository.Object, + _editRepository.Object, + Mock.Of(), + _reactionRepository.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + + [Test] + public async Task EditMessageAsync_is_refused_once_the_channel_is_frozen() + { + GivenChannelArchived(true); + + var result = await BuildService().EditMessageAsync(MessageId, SenderId, "rewritten after the fact"); + + result.Should().BeNull(); + _message.Body.Should().Be("original body"); + _messageRepository.Verify(x => x.UpdateBodyAsync(MessageId, It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task EditMessageAsync_still_works_while_the_incident_is_active() + { + GivenChannelArchived(false); + + var result = await BuildService().EditMessageAsync(MessageId, SenderId, "corrected"); + + result.Should().NotBeNull(); + result.Body.Should().Be("corrected"); + } + + [Test] + public async Task DeleteMessageAsync_refuses_the_author_once_the_channel_is_frozen() + { + GivenChannelArchived(true); + + var result = await BuildService().DeleteMessageAsync(MessageId, SenderId, asModerator: false, reason: null); + + result.Should().BeFalse(); + _messageRepository.Verify(x => x.TombstoneAsync(MessageId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task DeleteMessageAsync_still_lets_a_moderator_remove_flagged_content_when_frozen() + { + GivenChannelArchived(true); + + // Moderation has to keep working on a closed incident — that is the whole point of leaving + // flagging available on a frozen record. + var result = await BuildService().DeleteMessageAsync(MessageId, "moderator", asModerator: true, reason: "policy"); + + result.Should().BeTrue(); + _message.IsModerated.Should().BeTrue(); + _messageRepository.Verify(x => x.TombstoneAsync(MessageId, It.IsAny(), "moderator", true, It.IsAny()), Times.Once); + } + + [Test] + public async Task Reactions_are_refused_both_ways_once_the_channel_is_frozen() + { + GivenChannelArchived(true); + var service = BuildService(); + + (await service.AddReactionAsync(MessageId, SenderId, null, "👍")).Should().BeFalse(); + (await service.RemoveReactionAsync(MessageId, SenderId, null, "👍")).Should().BeFalse(); + + _reactionRepository.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _reactionRepository.Verify( + x => x.DeleteReactionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task A_missing_channel_reads_as_frozen_so_an_unanchored_edit_cannot_slip_through() + { + _channelRepository.Setup(x => x.GetByIdAsync(ChannelId)).ReturnsAsync((ChatChannel)null); + + var result = await BuildService().EditMessageAsync(MessageId, SenderId, "rewritten"); + + result.Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs new file mode 100644 index 000000000..9322bb6dd --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Incidents established before the incident chat channels existed heal themselves the first time + /// someone opens the board or the responder view — no migration sweep. This fixture pins the parts + /// that matter: only what is missing gets created, closed commands stay frozen, and a board that + /// refreshes on a timer does not re-sweep. + /// + [TestFixture] + public class ChatIncidentBackfillTests + { + private const int CallId = 42; + private const string CommandId = "command-1"; + + private Mock _channelRepository; + private Mock _cacheProvider; + private List _inserted; + + [SetUp] + public void Setup() + { + _channelRepository = new Mock(); + _cacheProvider = new Mock(); + _inserted = new List(); + + // No marker set: the backfill runs. + _cacheProvider.Setup(x => x.GetStringAsync(It.IsAny())).ReturnsAsync((string)null); + _cacheProvider.Setup(x => x.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + + _channelRepository + .Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ChatChannel channel, CancellationToken _, bool __) => + { + _inserted.Add(channel); + return channel; + }); + } + + private ChatChannelService BuildService() + => new ChatChannelService( + _channelRepository.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + _cacheProvider.Object, + Mock.Of()); + + private static IncidentCommand BuildCommand(IncidentCommandStatus status = IncidentCommandStatus.Active) + => new IncidentCommand + { + IncidentCommandId = CommandId, + DepartmentId = 1, + CallId = CallId, + Status = (int)status + }; + + private static CommandStructureNode BuildNode(string id, bool deleted = false) + => new CommandStructureNode + { + CommandStructureNodeId = id, + IncidentCommandId = CommandId, + DepartmentId = 1, + CallId = CallId, + Name = id, + DeletedOn = deleted ? DateTime.UtcNow : (DateTime?)null + }; + + private void GivenExistingChannels(params ChatChannel[] channels) + => _channelRepository.Setup(x => x.GetByCallIdAsync(CallId)).ReturnsAsync(new List(channels)); + + [Test] + public async Task an_incident_with_no_channels_gets_the_full_set() + { + GivenExistingChannels(); + + await BuildService().EnsureIncidentChannelsAsync(BuildCommand(), new[] { BuildNode("node-1"), BuildNode("node-2") }); + + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.Incident); + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentCommand); + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLeads); + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLane && c.CommandStructureNodeId == "node-1"); + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLane && c.CommandStructureNodeId == "node-2"); + } + + [Test] + public async Task only_the_missing_channels_are_created() + { + // An incident from after the command channel shipped but before "All Leads" did, with one of + // its two lanes already provisioned. + GivenExistingChannels( + new ChatChannel { ChatChannelId = "a", CallId = CallId, ChannelType = (int)ChatChannelType.Incident }, + new ChatChannel { ChatChannelId = "b", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentCommand }, + new ChatChannel { ChatChannelId = "c", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentLane, CommandStructureNodeId = "node-1" }); + + await BuildService().EnsureIncidentChannelsAsync(BuildCommand(), new[] { BuildNode("node-1"), BuildNode("node-2") }); + + _inserted.Should().HaveCount(2); + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLeads); + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLane && c.CommandStructureNodeId == "node-2"); + } + + [Test] + public async Task a_deleted_lane_does_not_get_a_channel() + { + GivenExistingChannels(); + + await BuildService().EnsureIncidentChannelsAsync(BuildCommand(), new[] { BuildNode("node-1"), BuildNode("node-gone", deleted: true) }); + + _inserted.Should().NotContain(c => c.CommandStructureNodeId == "node-gone"); + } + + [Test] + public async Task a_closed_command_is_left_alone() + { + GivenExistingChannels(); + + await BuildService().EnsureIncidentChannelsAsync(BuildCommand(IncidentCommandStatus.Closed), new[] { BuildNode("node-1") }); + + // Creating a channel now would come back unarchived and quietly unfreeze a point-in-time record. + _inserted.Should().BeEmpty(); + _channelRepository.Verify(x => x.GetByCallIdAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task a_recently_backfilled_incident_is_not_swept_again() + { + // The board is a polled read — without the marker every refresh would re-query the channels. + _cacheProvider.Setup(x => x.GetStringAsync(It.IsAny())).ReturnsAsync("1"); + + await BuildService().EnsureIncidentChannelsAsync(BuildCommand(), new[] { BuildNode("node-1") }); + + _channelRepository.Verify(x => x.GetByCallIdAsync(It.IsAny()), Times.Never); + _inserted.Should().BeEmpty(); + } + + [Test] + public async Task a_cache_outage_does_not_stop_the_backfill() + { + _cacheProvider.Setup(x => x.GetStringAsync(It.IsAny())).ThrowsAsync(new Exception("cache down")); + GivenExistingChannels(); + + await BuildService().EnsureIncidentChannelsAsync(BuildCommand(), new[] { BuildNode("node-1") }); + + _inserted.Should().NotBeEmpty(); + } + + [Test] + public async Task a_repository_failure_never_reaches_the_caller() + { + // The board read must survive a chat problem — provisioning is supplementary to it. + _channelRepository.Setup(x => x.GetByCallIdAsync(CallId)).ThrowsAsync(new Exception("db down")); + + var act = async () => await BuildService().EnsureIncidentChannelsAsync(BuildCommand(), new[] { BuildNode("node-1") }); + + await act.Should().NotThrowAsync(); + } + + [Test] + public async Task a_command_without_a_call_is_ignored() + { + var command = BuildCommand(); + command.CallId = 0; + + await BuildService().EnsureIncidentChannelsAsync(command, new[] { BuildNode("node-1") }); + + _inserted.Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs index 58cb86fb5..fb48ddf01 100644 --- a/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs @@ -915,5 +915,132 @@ public async Task incident_command_should_include_commander_and_role_holders_wit audience.Should().BeEquivalentTo(new[] { TestData.Users.TestUser1Id, TestData.Users.TestUser2Id, TestData.Users.TestUser3Id }); } } + + /// + /// The "All Leads" channel: the Incident Commander talking to the people running the lanes, and + /// nobody else. Membership is derived from the board on every check, so promoting or demoting a + /// lead changes access with no membership bookkeeping. + /// + [TestFixture] + public class when_evaluating_the_all_leads_channel : with_the_chat_permission_service + { + private static ChatChannel BuildLeadsChannel() + { + var channel = CreateChannel(ChatChannelType.IncidentLeads); + channel.CallId = 42; + return channel; + } + + private void GivenCommand(string commanderUserId) + { + _incidentCommandServiceMock.Setup(x => x.GetCommandForCallAsync(1, 42)).ReturnsAsync(new IncidentCommand + { + CallId = 42, + DepartmentId = 1, + CurrentCommanderUserId = commanderUserId, + EstablishedByUserId = commanderUserId + }); + } + + private void GivenLanes(params CommandStructureNode[] nodes) + { + _incidentCommandServiceMock.Setup(x => x.GetNodesForCallAsync(1, 42)).ReturnsAsync(new List(nodes)); + } + + [Test] + public async Task the_incident_commander_should_have_access() + { + GivenCommand(TestData.Users.TestUser1Id); + GivenLanes(); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildLeadsChannel(), TestData.Users.TestUser1Id, null); + + result.Should().BeTrue(); + } + + [TestCase(true)] + [TestCase(false)] + public async Task a_lane_lead_should_have_access(bool isPrimary) + { + GivenCommand(TestData.Users.TestUser1Id); + GivenLanes(new CommandStructureNode + { + CommandStructureNodeId = "node-1", + CallId = 42, + DepartmentId = 1, + PrimaryLeadUserId = isPrimary ? TestData.Users.TestUser2Id : null, + SecondaryLeadUserId = isPrimary ? null : TestData.Users.TestUser2Id + }); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildLeadsChannel(), TestData.Users.TestUser2Id, null); + + result.Should().BeTrue(); + } + + [Test] + public async Task a_lead_who_has_been_replaced_should_lose_access() + { + GivenCommand(TestData.Users.TestUser1Id); + // TestUser3 used to lead this lane; the board now shows TestUser2. + GivenLanes(new CommandStructureNode + { + CommandStructureNodeId = "node-1", + CallId = 42, + DepartmentId = 1, + PrimaryLeadUserId = TestData.Users.TestUser2Id + }); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildLeadsChannel(), TestData.Users.TestUser3Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task a_lead_on_a_deleted_lane_should_lose_access() + { + GivenCommand(TestData.Users.TestUser1Id); + GivenLanes(new CommandStructureNode + { + CommandStructureNodeId = "node-1", + CallId = 42, + DepartmentId = 1, + PrimaryLeadUserId = TestData.Users.TestUser2Id, + DeletedOn = DateTime.UtcNow + }); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildLeadsChannel(), TestData.Users.TestUser2Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task an_ics_role_holder_who_leads_no_lane_should_not_have_access() + { + GivenCommand(TestData.Users.TestUser1Id); + GivenLanes(); + _incidentCommandServiceMock.Setup(x => x.GetIncidentRolesAsync(1, 42)).ReturnsAsync(new List + { + new IncidentRoleAssignment { CallId = 42, UserId = TestData.Users.TestUser3Id } + }); + + // A Safety Officer belongs in the Command channel, not the leads channel. + var result = await _chatPermissionService.CanAccessChannelAsync(BuildLeadsChannel(), TestData.Users.TestUser3Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task the_audience_should_be_the_commander_and_every_lane_lead() + { + GivenCommand(TestData.Users.TestUser1Id); + GivenLanes( + new CommandStructureNode { CommandStructureNodeId = "node-1", CallId = 42, DepartmentId = 1, PrimaryLeadUserId = TestData.Users.TestUser2Id }, + new CommandStructureNode { CommandStructureNodeId = "node-2", CallId = 42, DepartmentId = 1, SecondaryLeadUserId = TestData.Users.TestUser3Id }); + + var audience = await _chatPermissionService.ResolveChannelAudienceUserIdsAsync(BuildLeadsChannel()); + + audience.Should().BeEquivalentTo(new[] { TestData.Users.TestUser1Id, TestData.Users.TestUser2Id, TestData.Users.TestUser3Id }); + } + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index bd395d80c..63d854bcc 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -94,17 +94,18 @@ public ChatController( /// 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. /// 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) + public async Task> GetChannels(int? activeUnitId = null, bool includeArchived = false) { if (!await ChatEnabledAsync()) return NotFound(); var result = new GetChatChannelsResult(); - var channels = await _chatChannelService.GetChannelsForUserAsync(DepartmentId, UserId, activeUnitId); + var channels = await _chatChannelService.GetChannelsForUserAsync(DepartmentId, UserId, activeUnitId, includeArchived); 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 3d26e7ed9..6eb511bd9 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -485,11 +485,12 @@ 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. Array of ChatChannelResultData objects for the channels the user can access From 12d7faa2d1a50ca6747aea93aa7da18521f9b272 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 10 Aug 2026 15:58:21 -0700 Subject: [PATCH 2/6] RC-T39 Permissions for Dispatch and IC --- Core/Resgrid.Model/Chat/ChatEnums.cs | 10 +- .../IncidentCommand/IncidentRole.cs | 13 + .../IncidentCommand/ResourceIncidentView.cs | 6 + Core/Resgrid.Model/PermissionTypes.cs | 18 +- Core/Resgrid.Model/Services/IChatServices.cs | 3 + .../Services/ICommandAccessService.cs | 37 + .../Services/IDispatchAccessService.cs | 29 + Core/Resgrid.Services/ChatChannelService.cs | 24 + .../Resgrid.Services/ChatPermissionService.cs | 32 +- Core/Resgrid.Services/CommandAccessService.cs | 33 + .../Resgrid.Services/DispatchAccessService.cs | 30 + .../IncidentCommandService.cs | 22 + .../PermissionGateServiceBase.cs | 178 +++ Core/Resgrid.Services/ServicesModule.cs | 2 + .../Services/ChatIncidentBackfillTests.cs | 4 +- .../Services/ChatPermissionServiceTests.cs | 134 ++ .../Services/DispatchAccessServiceTests.cs | 345 +++++ .../RequiresIncidentCapabilityFilterTests.cs | 96 +- .../v4/IncidentCommandController.cs | 32 +- .../Controllers/v4/SecurityController.cs | 4 + .../RequiresIncidentCapabilityAttribute.cs | 13 + .../v4/Security/DepartmentRightsResult.cs | 12 + .../Resgrid.Web.Services.xml | 1132 +++++++++-------- .../User/Controllers/SecurityController.cs | 26 + .../User/Models/Security/PermissionsView.cs | 6 + .../Areas/User/Views/Security/Index.cshtml | 20 + .../security/resgrid.security.permissions.js | 54 + 27 files changed, 1753 insertions(+), 562 deletions(-) create mode 100644 Core/Resgrid.Model/Services/ICommandAccessService.cs create mode 100644 Core/Resgrid.Model/Services/IDispatchAccessService.cs create mode 100644 Core/Resgrid.Services/CommandAccessService.cs create mode 100644 Core/Resgrid.Services/DispatchAccessService.cs create mode 100644 Core/Resgrid.Services/PermissionGateServiceBase.cs create mode 100644 Tests/Resgrid.Tests/Services/DispatchAccessServiceTests.cs diff --git a/Core/Resgrid.Model/Chat/ChatEnums.cs b/Core/Resgrid.Model/Chat/ChatEnums.cs index 0f01761b2..1b246df3a 100644 --- a/Core/Resgrid.Model/Chat/ChatEnums.cs +++ b/Core/Resgrid.Model/Chat/ChatEnums.cs @@ -18,7 +18,15 @@ public enum ChatChannelType /// people running the lanes, without the lane crews. Membership is derived live from the lanes, /// so demoting a lead removes their access on the next check. /// - IncidentLeads = 9 + IncidentLeads = 9, + + /// + /// The incident's line to the dispatch desk: everyone working the incident on one side, every + /// dispatch-authorized user on the other. Per-call rather than department-wide so dispatchers can + /// tell which incident is talking to them, and audience-wide on the dispatch side so whichever + /// dispatcher is on shift picks it up. + /// + IncidentDispatch = 10 } /// Who a chat participant is: a person, a unit-shared identity ("Engine 6"), or the chatbot. diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs b/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs index 8f0b381e2..888731f03 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs @@ -72,6 +72,19 @@ public enum IncidentCapabilities /// public static class IncidentRoleCapabilityMap { + /// + /// What a command-authorized user gets on a board they hold no ICS role on — the "help work the + /// board" subset: see it, move resources on and off, bring ad-hoc resources in, and keep the + /// timers and accountability running. + /// + /// Deliberately excludes ManageCommand (closing, transferring, the action plan) and + /// ManageStructure (creating and deleting lanes): assisting is not commanding, and the shape of + /// the incident stays with whoever actually holds it. + /// + public const IncidentCapabilities CommandAssistCapabilities = + IncidentCapabilities.ViewBoard | IncidentCapabilities.AssignResources | IncidentCapabilities.ManageResources | + IncidentCapabilities.ManageTimers | IncidentCapabilities.ManageAccountability; + public static IncidentCapabilities GetCapabilities(IncidentRoleType role) { switch (role) diff --git a/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs b/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs index 517d1d2b5..8616742bd 100644 --- a/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs +++ b/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs @@ -84,6 +84,12 @@ public class IncidentChatChannels /// The caller's own lane channel, when they are assigned to a lane. public string LaneChannelId { get; set; } + /// + /// The incident's line to the dispatch desk. Available to everyone on the incident — a crew + /// needing dispatch shouldn't have to route through command to reach them. + /// + public string DispatchChannelId { get; set; } + /// True once the incident is closed: the conversations are readable but frozen. public bool IsFrozen { get; set; } } diff --git a/Core/Resgrid.Model/PermissionTypes.cs b/Core/Resgrid.Model/PermissionTypes.cs index e2029d68f..9accd3141 100644 --- a/Core/Resgrid.Model/PermissionTypes.cs +++ b/Core/Resgrid.Model/PermissionTypes.cs @@ -30,7 +30,23 @@ public enum PermissionTypes ViewUdfFields = 25, ManageRoutes = 26, DeleteLog = 27, - UseCalendarSync = 28 + UseCalendarSync = 28, + + /// + /// Who may sign in to the Dispatch app. Defaults to everyone in the department (no permission + /// row = allowed, per IPermissionsService.IsUserAllowed), and can be narrowed to admins, group + /// admins, or selected personnel roles. Dispatch surfaces private command, unit and responder + /// traffic, so a department that isn't all-dispatchers should restrict this. + /// + DispatchAppLogin = 29, + + /// + /// Who may act as a commander: sign in to the IC app, establish incident command on a call, and + /// read command boards. Defaults to everyone in the department (no permission row = allowed) and + /// can be narrowed to admins, group admins, or selected personnel roles — the same ladder as + /// . + /// + CommandAppLogin = 30 } } diff --git a/Core/Resgrid.Model/Services/IChatServices.cs b/Core/Resgrid.Model/Services/IChatServices.cs index 1a4c43aef..82855199b 100644 --- a/Core/Resgrid.Model/Services/IChatServices.cs +++ b/Core/Resgrid.Model/Services/IChatServices.cs @@ -106,6 +106,9 @@ public interface IChatChannelService /// Ensures the incident's "All Leads" channel: the IC and every lane's primary/secondary lead. Task EnsureLeadsChannelAsync(IncidentCommand command, CancellationToken cancellationToken = default(CancellationToken)); + /// Ensures the incident's line to the dispatch desk. + Task EnsureDispatchChannelAsync(int departmentId, int callId, string incidentCommandId, CancellationToken cancellationToken = default(CancellationToken)); + /// /// Backfills every chat channel an ACTIVE incident should have — the call's incident channel, the /// command and "All Leads" channels, and one per live lane — inserting only what is missing. diff --git a/Core/Resgrid.Model/Services/ICommandAccessService.cs b/Core/Resgrid.Model/Services/ICommandAccessService.cs new file mode 100644 index 000000000..5098824cf --- /dev/null +++ b/Core/Resgrid.Model/Services/ICommandAccessService.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Who is allowed to act as a commander, per the + /// permission: signing in to the IC app, establishing command on a call, and reading command boards. + /// + /// The mirror of , and enforced the same way — on the server, not + /// just in the app. A command board is only a client of the shared API, so a client-side check alone + /// would keep nothing private. + /// + /// Defaults to allowing everyone in the department, so departments that never configure it are + /// unaffected. + /// + public interface ICommandAccessService + { + /// True when this user may act as a commander for the department. + Task CanUseCommandAsync(int departmentId, string userId); + + /// Every user in the department who may act as a commander. + Task> GetCommandUserIdsAsync(int departmentId); + + /// + /// True when this user may ASSIST on a command board they hold no ICS role on — the capability set + /// a dispatcher needs to help work an incident. + /// + /// Stricter than on purpose: it additionally requires the + /// department to have deliberately narrowed . The + /// permission defaults to Everyone so nothing breaks on upgrade, and inferring "therefore every + /// member may move resources on any board" from that open default would hand out authority no one + /// asked for. Once a department picks who commands, those people are trusted to assist. + /// + Task CanAssistWithCommandAsync(int departmentId, string userId); + } +} diff --git a/Core/Resgrid.Model/Services/IDispatchAccessService.cs b/Core/Resgrid.Model/Services/IDispatchAccessService.cs new file mode 100644 index 000000000..6b2e500ef --- /dev/null +++ b/Core/Resgrid.Model/Services/IDispatchAccessService.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Who is allowed to work the dispatch desk, per the + /// permission. + /// + /// This is the single authority for that question. It gates signing in to the Dispatch app AND + /// membership of the incident dispatch chat channel — the app is only a client of the shared API, so + /// a client-side check alone would keep nothing private. Anyone the department hasn't authorized + /// simply resolves to no dispatch channel, whichever app they are running. + /// + /// Defaults to allowing everyone in the department: departments that are entirely dispatchers, or + /// that have never configured the permission, keep working unchanged. + /// + public interface IDispatchAccessService + { + /// True when this user may work dispatch for the department. + Task CanUseDispatchAsync(int departmentId, string userId); + + /// + /// Every user in the department who may work dispatch — the audience for anything addressed to + /// "Dispatch", since whichever dispatcher is on shift needs to see it. + /// + Task> GetDispatchUserIdsAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs index 7d84d0ab8..362a5feb7 100644 --- a/Core/Resgrid.Services/ChatChannelService.cs +++ b/Core/Resgrid.Services/ChatChannelService.cs @@ -727,6 +727,27 @@ public async Task GetUserMembershipAsync(string chatChannelId }, () => _chatChannelRepository.GetByCallIdAndTypeAsync(command.CallId, (int)ChatChannelType.IncidentLeads), cancellationToken); } + public async Task EnsureDispatchChannelAsync(int departmentId, int callId, string incidentCommandId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (callId <= 0) + return null; + + var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)ChatChannelType.IncidentDispatch); + if (existing != null) + return existing; + + return await InsertProvisionedChannelAsync(new ChatChannel + { + ChatChannelId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ChannelType = (int)ChatChannelType.IncidentDispatch, + Name = "Dispatch", + CallId = callId, + IncidentCommandId = incidentCommandId, + CreatedOn = DateTime.UtcNow + }, () => _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)ChatChannelType.IncidentDispatch), cancellationToken); + } + public async Task EnsureIncidentChannelsAsync(IncidentCommand command, IEnumerable nodes, CancellationToken cancellationToken = default(CancellationToken)) { if (command == null || command.CallId <= 0) @@ -764,6 +785,9 @@ public async Task GetUserMembershipAsync(string chatChannelId if (!existing.Any(c => c.ChannelType == (int)ChatChannelType.IncidentLeads)) await EnsureLeadsChannelAsync(command, cancellationToken); + if (!existing.Any(c => c.ChannelType == (int)ChatChannelType.IncidentDispatch)) + await EnsureDispatchChannelAsync(command.DepartmentId, command.CallId, command.IncidentCommandId, cancellationToken); + var provisionedNodeIds = new HashSet( existing.Where(c => c.ChannelType == (int)ChatChannelType.IncidentLane && !string.IsNullOrWhiteSpace(c.CommandStructureNodeId)) .Select(c => c.CommandStructureNodeId), diff --git a/Core/Resgrid.Services/ChatPermissionService.cs b/Core/Resgrid.Services/ChatPermissionService.cs index 8afc31900..ad40ae3ad 100644 --- a/Core/Resgrid.Services/ChatPermissionService.cs +++ b/Core/Resgrid.Services/ChatPermissionService.cs @@ -33,12 +33,13 @@ public class ChatPermissionService : IChatPermissionService private readonly IUnitsService _unitsService; private readonly ICallsService _callsService; private readonly IIncidentCommandService _incidentCommandService; + private readonly IDispatchAccessService _dispatchAccessService; private readonly ICacheProvider _cacheProvider; public ChatPermissionService(IChatChannelMemberRepository chatChannelMemberRepository, IChatChannelAccessRuleRepository chatChannelAccessRuleRepository, IAuthorizationService authorizationService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, IUnitsService unitsService, ICallsService callsService, - IIncidentCommandService incidentCommandService, ICacheProvider cacheProvider) + IIncidentCommandService incidentCommandService, IDispatchAccessService dispatchAccessService, ICacheProvider cacheProvider) { _chatChannelMemberRepository = chatChannelMemberRepository; _chatChannelAccessRuleRepository = chatChannelAccessRuleRepository; @@ -49,6 +50,7 @@ public ChatPermissionService(IChatChannelMemberRepository chatChannelMemberRepos _unitsService = unitsService; _callsService = callsService; _incidentCommandService = incidentCommandService; + _dispatchAccessService = dispatchAccessService; _cacheProvider = cacheProvider; } @@ -193,6 +195,9 @@ public async Task> ResolveChannelAudienceUserIdsAsync(ChatChannel c case ChatChannelType.Incident: await AddIncidentAudienceAsync(channel, userIds); + // The desk follows the incident's shared conversation, not just its own dispatch line. + foreach (var dispatcherId in await _dispatchAccessService.GetDispatchUserIdsAsync(channel.DepartmentId)) + AddIfSet(userIds, dispatcherId); break; case ChatChannelType.IncidentLane: @@ -207,6 +212,12 @@ public async Task> ResolveChannelAudienceUserIdsAsync(ChatChannel c await AddLaneLeadsAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault(), userIds); break; + case ChatChannelType.IncidentDispatch: + await AddIncidentAudienceAsync(channel, userIds); + foreach (var dispatcherId in await _dispatchAccessService.GetDispatchUserIdsAsync(channel.DepartmentId)) + AddIfSet(userIds, dispatcherId); + break; + default: // DirectMessage, AdHocGroup await AddExplicitMemberAudienceAsync(channel, userIds); break; @@ -263,6 +274,11 @@ private async Task EvaluateAccessAsync(ChatChannel channel, string userId, if (await IsDepartmentAdminAsync(channel.DepartmentId, userId)) return true; + // Authorized dispatchers see every call's shared incident conversation — that is the + // desk's job. The private command channel stays closed to them. + if (await _dispatchAccessService.CanUseDispatchAsync(channel.DepartmentId, userId)) + return true; + return await IsInIncidentAudienceAsync(channel, userId, activeUnitId); case ChatChannelType.IncidentLane: @@ -275,6 +291,9 @@ private async Task EvaluateAccessAsync(ChatChannel channel, string userId, if (await IsDepartmentAdminAsync(channel.DepartmentId, userId)) return true; + // Command staff ONLY — deliberately not widened to dispatch. Dispatch reaches command + // through the incident's dispatch channel; this one stays internal to the people running + // the incident so command can talk candidly. return await IsCommandStaffAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault(), userId); case ChatChannelType.IncidentLeads: @@ -283,6 +302,16 @@ private async Task EvaluateAccessAsync(ChatChannel channel, string userId, return await IsLaneLeadOrCommanderAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault(), userId); + case ChatChannelType.IncidentDispatch: + // Deliberately NOT widened to department admins the way the other incident channels are. + // The whole point of the DispatchAppLogin permission is that an admin the department has + // not authorized for dispatch stays out of dispatch traffic; they still get in if they + // are actually working the incident. + if (await _dispatchAccessService.CanUseDispatchAsync(channel.DepartmentId, userId)) + return true; + + return await IsInIncidentAudienceAsync(channel, userId, activeUnitId); + default: return false; } @@ -311,6 +340,7 @@ private async Task EvaluateModerateAsync(ChatChannel channel, string userI case ChatChannelType.IncidentLane: case ChatChannelType.IncidentCommand: case ChatChannelType.IncidentLeads: + case ChatChannelType.IncidentDispatch: if (!channel.CallId.HasValue) return false; diff --git a/Core/Resgrid.Services/CommandAccessService.cs b/Core/Resgrid.Services/CommandAccessService.cs new file mode 100644 index 000000000..c70490b50 --- /dev/null +++ b/Core/Resgrid.Services/CommandAccessService.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public class CommandAccessService : PermissionGateServiceBase, ICommandAccessService + { + public CommandAccessService( + IPermissionsService permissionsService, + IDepartmentsService departmentsService, + IDepartmentGroupsService departmentGroupsService, + IPersonnelRolesService personnelRolesService, + ICacheProvider cacheProvider) + : base(permissionsService, departmentsService, departmentGroupsService, personnelRolesService, cacheProvider) + { + } + + protected override PermissionTypes PermissionType => PermissionTypes.CommandAppLogin; + + protected override string CacheKeyPrefix => "commandaccess"; + + public Task CanUseCommandAsync(int departmentId, string userId) => IsAllowedAsync(departmentId, userId); + + public Task> GetCommandUserIdsAsync(int departmentId) => GetAllowedUserIdsAsync(departmentId); + + public async Task CanAssistWithCommandAsync(int departmentId, string userId) + => await IsRestrictedAsync(departmentId) && await IsAllowedAsync(departmentId, userId); + } +} diff --git a/Core/Resgrid.Services/DispatchAccessService.cs b/Core/Resgrid.Services/DispatchAccessService.cs new file mode 100644 index 000000000..5c6e8b04f --- /dev/null +++ b/Core/Resgrid.Services/DispatchAccessService.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public class DispatchAccessService : PermissionGateServiceBase, IDispatchAccessService + { + public DispatchAccessService( + IPermissionsService permissionsService, + IDepartmentsService departmentsService, + IDepartmentGroupsService departmentGroupsService, + IPersonnelRolesService personnelRolesService, + ICacheProvider cacheProvider) + : base(permissionsService, departmentsService, departmentGroupsService, personnelRolesService, cacheProvider) + { + } + + protected override PermissionTypes PermissionType => PermissionTypes.DispatchAppLogin; + + protected override string CacheKeyPrefix => "dispatchaccess"; + + public Task CanUseDispatchAsync(int departmentId, string userId) => IsAllowedAsync(departmentId, userId); + + public Task> GetDispatchUserIdsAsync(int departmentId) => GetAllowedUserIdsAsync(departmentId); + } +} diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index 62a2fc2b7..69c8655f4 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -470,6 +470,25 @@ public async Task GetCapabilitiesForUserAsync(int departme foreach (var role in roles.Where(r => string.Equals(r.UserId, userId))) caps |= IncidentRoleCapabilityMap.GetCapabilities((IncidentRoleType)role.RoleType); + // A department that has deliberately chosen who commands can let those people assist on a board + // without holding an ICS role on it — that is how a dispatcher helps work an incident from the + // Dispatch app. CanAssistWithCommandAsync (not CanUseCommandAsync) is the right question: the + // permission is open by default, and granting board authority off that open default would hand + // every member rights nobody asked for. + // Resolved through the service locator (matching this file's other cross-cutting lookups) so the + // permission side, which has no dependency on this service, does not close a DI cycle. + try + { + if (await ServiceLocator.Current.GetInstance().CanAssistWithCommandAsync(departmentId, userId)) + caps |= IncidentRoleCapabilityMap.CommandAssistCapabilities; + } + catch (Exception ex) + { + // Fail closed: an unresolvable permission grants nothing extra, leaving the caller with + // whatever their commander standing and ICS roles already earned them. + Resgrid.Framework.Logging.LogException(ex); + } + return caps; } @@ -1099,6 +1118,9 @@ private async Task PopulateResourceViewContactsAndChatAsync(ResourceIncidentView view.Chat.IncidentChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.Incident)?.ChatChannelId; + // Anyone on the incident can raise dispatch; no command standing required. + view.Chat.DispatchChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentDispatch)?.ChatChannelId; + if (isCommandStaff) view.Chat.CommandChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentCommand)?.ChatChannelId; diff --git a/Core/Resgrid.Services/PermissionGateServiceBase.cs b/Core/Resgrid.Services/PermissionGateServiceBase.cs new file mode 100644 index 000000000..938519075 --- /dev/null +++ b/Core/Resgrid.Services/PermissionGateServiceBase.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Shared evaluation for the "who may act in this capacity" permissions — dispatch and command. + /// + /// Both answer the same question against a different value, and both + /// gate access to private traffic, so the rules that matter live in one place: a missing permission + /// row means everyone, the department's managing user counts as an admin, and an evaluation failure + /// denies rather than allows. + /// + public abstract class PermissionGateServiceBase + { + private static readonly TimeSpan CacheLength = TimeSpan.FromSeconds(60); + + private readonly IPermissionsService _permissionsService; + private readonly IDepartmentsService _departmentsService; + private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IPersonnelRolesService _personnelRolesService; + private readonly ICacheProvider _cacheProvider; + + protected PermissionGateServiceBase( + IPermissionsService permissionsService, + IDepartmentsService departmentsService, + IDepartmentGroupsService departmentGroupsService, + IPersonnelRolesService personnelRolesService, + ICacheProvider cacheProvider) + { + _permissionsService = permissionsService; + _departmentsService = departmentsService; + _departmentGroupsService = departmentGroupsService; + _personnelRolesService = personnelRolesService; + _cacheProvider = cacheProvider; + } + + /// The permission this gate evaluates. + protected abstract PermissionTypes PermissionType { get; } + + /// Cache key prefix, unique per gate so the two never share a verdict. + protected abstract string CacheKeyPrefix { get; } + + protected async Task IsAllowedAsync(int departmentId, string userId) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId)) + return false; + + var cacheKey = $"{CacheKeyPrefix}:{departmentId}:{userId}"; + + try + { + var cached = await _cacheProvider.GetStringAsync(cacheKey); + if (cached == "1") + return true; + if (cached == "0") + return false; + } + catch (Exception ex) + { + // A cache outage must not lock people out — fall through and evaluate. + Logging.LogException(ex); + } + + var allowed = await EvaluateAsync(departmentId, userId); + + try + { + await _cacheProvider.SetStringAsync(cacheKey, allowed ? "1" : "0", CacheLength); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + + return allowed; + } + + /// + /// True when the department has deliberately narrowed this permission — a row exists and it is not + /// "Everyone". + /// + /// Used where granting a capability off the back of the OPEN default would be a surprise: with no + /// row, or a row that admits everyone, the department has expressed no opinion about who is + /// trusted, so nothing extra should be inferred from it. + /// + protected async Task IsRestrictedAsync(int departmentId) + { + if (departmentId <= 0) + return false; + + try + { + var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionType); + return permission != null && permission.Action != (int)PermissionActions.Everyone; + } + catch (Exception ex) + { + // Unreadable means "no opinion expressed", which grants nothing extra. + Logging.LogException(ex); + return false; + } + } + + protected async Task> GetAllowedUserIdsAsync(int departmentId) + { + if (departmentId <= 0) + return new List(); + + var members = await _departmentsService.GetAllMembersForDepartmentAsync(departmentId) ?? new List(); + var active = members + .Where(m => !m.IsDisabled.GetValueOrDefault() && !m.IsDeleted && !string.IsNullOrWhiteSpace(m.UserId)) + .ToList(); + + var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionType); + + // The overwhelmingly common case — no permission row, or one that allows everyone — needs no + // per-user evaluation at all. Only a genuinely restricted department pays for the fan-out. + if (permission == null || permission.Action == (int)PermissionActions.Everyone) + return active.Select(m => m.UserId).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + var allowed = new List(); + foreach (var member in active) + { + if (await IsAllowedAsync(departmentId, member.UserId)) + allowed.Add(member.UserId); + } + + return allowed.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// + /// Mirrors how the department rights endpoint decides every other permission: department admin, + /// group admin, and the user's personnel roles evaluated against the permission row. A missing row + /// means everyone, which already handles. + /// + private async Task EvaluateAsync(int departmentId, string userId) + { + try + { + var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionType); + if (permission == null) + return true; + + var membership = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, false); + if (membership == null) + return false; + + var isDepartmentAdmin = membership.IsAdmin.GetValueOrDefault(); + + // The department's managing user is always an admin, the same carve-out the rights endpoint makes. + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + if (department != null && string.Equals(department.ManagingUserId, userId, StringComparison.OrdinalIgnoreCase)) + isDepartmentAdmin = true; + + var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); + var isGroupAdmin = group != null && group.IsUserGroupAdmin(userId); + + var roles = await _personnelRolesService.GetRolesForUserAsync(userId, departmentId); + + return _permissionsService.IsUserAllowed(permission, isDepartmentAdmin, isGroupAdmin, roles); + } + catch (Exception ex) + { + // Fail CLOSED. These gates exist to keep private command, unit, responder and dispatch + // traffic away from people the department hasn't authorized; an error must not hand it over. + Logging.LogException(ex); + return false; + } + } + } +} diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index ef0b45471..3be41aa8d 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -18,6 +18,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs index 9322bb6dd..14f9280c8 100644 --- a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs @@ -98,6 +98,7 @@ public async Task an_incident_with_no_channels_gets_the_full_set() _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.Incident); _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentCommand); _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLeads); + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentDispatch); _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLane && c.CommandStructureNodeId == "node-1"); _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLane && c.CommandStructureNodeId == "node-2"); } @@ -114,8 +115,9 @@ public async Task only_the_missing_channels_are_created() await BuildService().EnsureIncidentChannelsAsync(BuildCommand(), new[] { BuildNode("node-1"), BuildNode("node-2") }); - _inserted.Should().HaveCount(2); + _inserted.Should().HaveCount(3); _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLeads); + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentDispatch); _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLane && c.CommandStructureNodeId == "node-2"); } diff --git a/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs index fb48ddf01..93b57f30f 100644 --- a/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs @@ -28,6 +28,7 @@ public class with_the_chat_permission_service : TestBase protected Mock _unitsServiceMock; protected Mock _callsServiceMock; protected Mock _incidentCommandServiceMock; + protected Mock _dispatchAccessServiceMock; protected Mock _cacheProviderMock; protected with_the_chat_permission_service() @@ -53,6 +54,7 @@ private void BuildService() _unitsServiceMock = new Mock(); _callsServiceMock = new Mock(); _incidentCommandServiceMock = new Mock(); + _dispatchAccessServiceMock = new Mock(); _cacheProviderMock = new Mock(); // No cached results so the evaluation logic always runs. @@ -62,6 +64,10 @@ private void BuildService() // Default: nobody is a department admin unless a test says otherwise. _authorizationServiceMock.Setup(x => x.CanUserModifyDepartmentAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + // Default: nobody is authorized for dispatch unless a test opts in. + _dispatchAccessServiceMock.Setup(x => x.CanUseDispatchAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + _dispatchAccessServiceMock.Setup(x => x.GetDispatchUserIdsAsync(It.IsAny())).ReturnsAsync(new List()); + _chatPermissionService = new ChatPermissionService( _chatChannelMemberRepositoryMock.Object, _chatChannelAccessRuleRepositoryMock.Object, @@ -72,6 +78,7 @@ private void BuildService() _unitsServiceMock.Object, _callsServiceMock.Object, _incidentCommandServiceMock.Object, + _dispatchAccessServiceMock.Object, _cacheProviderMock.Object); } @@ -547,6 +554,36 @@ public async Task active_role_holder_should_have_access_to_command_channel() result.Should().BeTrue(); } + [Test] + public async Task an_authorized_dispatcher_should_not_have_access_to_command_channel() + { + // The command channel stays internal to the people running the incident so they can talk + // candidly. Dispatch reaches command through the incident's dispatch channel instead. + var channel = CreateChannel(ChatChannelType.IncidentCommand); + channel.CallId = 42; + _incidentCommandServiceMock.Setup(x => x.GetCommandForCallAsync(1, 42)).ReturnsAsync((IncidentCommand)null); + _incidentCommandServiceMock.Setup(x => x.GetIncidentRolesAsync(1, 42)).ReturnsAsync(new List()); + _dispatchAccessServiceMock.Setup(x => x.CanUseDispatchAsync(1, TestData.Users.TestUser1Id)).ReturnsAsync(true); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task an_authorized_dispatcher_should_have_access_to_the_shared_incident_channel() + { + // The call-wide conversation everyone on the incident shares — the desk follows it even + // when nobody has dispatched them to the call. + var channel = CreateChannel(ChatChannelType.Incident); + channel.CallId = 42; + _dispatchAccessServiceMock.Setup(x => x.CanUseDispatchAsync(1, TestData.Users.TestUser1Id)).ReturnsAsync(true); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); + + result.Should().BeTrue(); + } + [Test] public async Task dispatched_user_who_is_not_command_staff_should_not_have_access_to_command_channel() { @@ -1042,5 +1079,102 @@ public async Task the_audience_should_be_the_commander_and_every_lane_lead() audience.Should().BeEquivalentTo(new[] { TestData.Users.TestUser1Id, TestData.Users.TestUser2Id, TestData.Users.TestUser3Id }); } } + + /// + /// The incident's line to the dispatch desk. Two ways in — being on the incident, or being + /// authorized to work dispatch — and department-admin alone is deliberately not one of them. + /// + [TestFixture] + public class when_evaluating_the_incident_dispatch_channel : with_the_chat_permission_service + { + private static ChatChannel BuildDispatchChannel() + { + var channel = CreateChannel(ChatChannelType.IncidentDispatch); + channel.CallId = 42; + return channel; + } + + private void GivenNobodyOnTheIncident() + { + _incidentCommandServiceMock.Setup(x => x.GetCommandForCallAsync(1, 42)).ReturnsAsync((IncidentCommand)null); + _incidentCommandServiceMock.Setup(x => x.GetIncidentRolesAsync(1, 42)).ReturnsAsync(new List()); + _incidentCommandServiceMock.Setup(x => x.GetAssignmentsForCallAsync(1, 42)).ReturnsAsync(new List()); + _callsServiceMock.Setup(x => x.GetCallByIdAsync(42, It.IsAny())).ReturnsAsync(new Call { CallId = 42, DepartmentId = 1 }); + } + + [Test] + public async Task an_authorized_dispatcher_should_have_access() + { + GivenNobodyOnTheIncident(); + _dispatchAccessServiceMock.Setup(x => x.CanUseDispatchAsync(1, TestData.Users.TestUser1Id)).ReturnsAsync(true); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildDispatchChannel(), TestData.Users.TestUser1Id, null); + + result.Should().BeTrue(); + } + + [Test] + public async Task someone_dispatched_to_the_call_should_have_access() + { + // A crew raising dispatch does not need dispatch authorization themselves. + _callsServiceMock.Setup(x => x.GetCallByIdAsync(42, It.IsAny())).ReturnsAsync(new Call + { + CallId = 42, + DepartmentId = 1, + Dispatches = new List { new CallDispatch { CallId = 42, UserId = TestData.Users.TestUser2Id } } + }); + _incidentCommandServiceMock.Setup(x => x.GetCommandForCallAsync(1, 42)).ReturnsAsync((IncidentCommand)null); + _incidentCommandServiceMock.Setup(x => x.GetIncidentRolesAsync(1, 42)).ReturnsAsync(new List()); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildDispatchChannel(), TestData.Users.TestUser2Id, null); + + result.Should().BeTrue(); + } + + [Test] + public async Task an_unauthorized_member_who_is_not_on_the_incident_should_be_refused() + { + GivenNobodyOnTheIncident(); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildDispatchChannel(), TestData.Users.TestUser3Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task a_department_admin_without_dispatch_authorization_should_be_refused() + { + // Every other incident channel widens to department admins. This one must not: the point of + // the permission is that an admin who is not a dispatcher stays out of dispatch traffic. + GivenNobodyOnTheIncident(); + _authorizationServiceMock.Setup(x => x.CanUserModifyDepartmentAsync(TestData.Users.TestUser3Id, 1)).ReturnsAsync(true); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildDispatchChannel(), TestData.Users.TestUser3Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task the_audience_should_be_the_incident_plus_every_dispatcher() + { + _callsServiceMock.Setup(x => x.GetCallByIdAsync(42, It.IsAny())).ReturnsAsync(new Call + { + CallId = 42, + DepartmentId = 1, + Dispatches = new List { new CallDispatch { CallId = 42, UserId = TestData.Users.TestUser1Id } } + }); + _incidentCommandServiceMock.Setup(x => x.GetCommandForCallAsync(1, 42)).ReturnsAsync((IncidentCommand)null); + _incidentCommandServiceMock.Setup(x => x.GetIncidentRolesAsync(1, 42)).ReturnsAsync(new List()); + _incidentCommandServiceMock.Setup(x => x.GetAssignmentsForCallAsync(1, 42)).ReturnsAsync(new List()); + _dispatchAccessServiceMock.Setup(x => x.GetDispatchUserIdsAsync(1)) + .ReturnsAsync(new List { TestData.Users.TestUser2Id, TestData.Users.TestUser3Id }); + + var audience = await _chatPermissionService.ResolveChannelAudienceUserIdsAsync(BuildDispatchChannel()); + + audience.Should().Contain(TestData.Users.TestUser1Id); + audience.Should().Contain(TestData.Users.TestUser2Id); + audience.Should().Contain(TestData.Users.TestUser3Id); + } + } } } diff --git a/Tests/Resgrid.Tests/Services/DispatchAccessServiceTests.cs b/Tests/Resgrid.Tests/Services/DispatchAccessServiceTests.cs new file mode 100644 index 000000000..64a3d33c4 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DispatchAccessServiceTests.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Who may work the dispatch desk. This gate keeps private command, unit and responder traffic away + /// from members the department hasn't authorized, so the interesting cases are the defaults (nothing + /// configured must keep working) and the failure modes (an error must not hand access over). + /// + [TestFixture] + public class DispatchAccessServiceTests + { + private const int DepartmentId = 1; + private const string UserId = "user-1"; + + private Mock _permissionsService; + private Mock _departmentsService; + private Mock _departmentGroupsService; + private Mock _personnelRolesService; + private Mock _cacheProvider; + + [SetUp] + public void Setup() + { + _permissionsService = new Mock(); + _departmentsService = new Mock(); + _departmentGroupsService = new Mock(); + _personnelRolesService = new Mock(); + _cacheProvider = new Mock(); + + // No cached verdict so the evaluation always runs. + _cacheProvider.Setup(x => x.GetStringAsync(It.IsAny())).ReturnsAsync((string)null); + _cacheProvider.Setup(x => x.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(new Department { DepartmentId = DepartmentId, ManagingUserId = "owner" }); + _departmentsService.Setup(x => x.GetDepartmentMemberAsync(UserId, DepartmentId, It.IsAny())) + .ReturnsAsync(new DepartmentMember { UserId = UserId, DepartmentId = DepartmentId, IsAdmin = false }); + _personnelRolesService.Setup(x => x.GetRolesForUserAsync(UserId, DepartmentId)).ReturnsAsync(new List()); + } + + private DispatchAccessService BuildService() + => new DispatchAccessService( + _permissionsService.Object, + _departmentsService.Object, + _departmentGroupsService.Object, + _personnelRolesService.Object, + _cacheProvider.Object); + + private void GivenPermission(Permission permission) + => _permissionsService.Setup(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.DispatchAppLogin)) + .ReturnsAsync(permission); + + [Test] + public async Task everyone_is_allowed_when_the_department_has_not_configured_the_permission() + { + // The default has to stay open, or upgrading Core would lock every existing department out. + GivenPermission(null); + + var result = await BuildService().CanUseDispatchAsync(DepartmentId, UserId); + + result.Should().BeTrue(); + } + + [Test] + public async Task the_configured_permission_decides_when_one_exists() + { + var permission = new Permission { DepartmentId = DepartmentId, Action = (int)PermissionActions.DepartmentAdminsOnly }; + GivenPermission(permission); + _permissionsService.Setup(x => x.IsUserAllowed(permission, false, false, It.IsAny>())).Returns(false); + + var result = await BuildService().CanUseDispatchAsync(DepartmentId, UserId); + + result.Should().BeFalse(); + } + + [Test] + public async Task the_departments_managing_user_counts_as_an_admin() + { + var permission = new Permission { DepartmentId = DepartmentId, Action = (int)PermissionActions.DepartmentAdminsOnly }; + GivenPermission(permission); + _departmentsService.Setup(x => x.GetDepartmentMemberAsync("owner", DepartmentId, It.IsAny())) + .ReturnsAsync(new DepartmentMember { UserId = "owner", DepartmentId = DepartmentId, IsAdmin = false }); + _personnelRolesService.Setup(x => x.GetRolesForUserAsync("owner", DepartmentId)).ReturnsAsync(new List()); + _permissionsService.Setup(x => x.IsUserAllowed(permission, true, false, It.IsAny>())).Returns(true); + + var result = await BuildService().CanUseDispatchAsync(DepartmentId, "owner"); + + result.Should().BeTrue(); + } + + [Test] + public async Task someone_who_is_not_a_member_of_the_department_is_refused() + { + GivenPermission(new Permission { DepartmentId = DepartmentId, Action = (int)PermissionActions.Everyone }); + _departmentsService.Setup(x => x.GetDepartmentMemberAsync("stranger", DepartmentId, It.IsAny())) + .ReturnsAsync((DepartmentMember)null); + + var result = await BuildService().CanUseDispatchAsync(DepartmentId, "stranger"); + + result.Should().BeFalse(); + } + + [Test] + public async Task an_evaluation_failure_fails_closed() + { + // Erring open here would leak private traffic, which is the exact thing this gate exists for. + _permissionsService.Setup(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.DispatchAppLogin)) + .ThrowsAsync(new Exception("db down")); + + var result = await BuildService().CanUseDispatchAsync(DepartmentId, UserId); + + result.Should().BeFalse(); + } + + [TestCase(null)] + [TestCase("")] + public async Task a_missing_user_is_refused(string userId) + { + var result = await BuildService().CanUseDispatchAsync(DepartmentId, userId); + + result.Should().BeFalse(); + } + + [Test] + public async Task the_dispatch_audience_is_the_whole_department_by_default() + { + GivenPermission(null); + _departmentsService.Setup(x => x.GetAllMembersForDepartmentAsync(DepartmentId)).ReturnsAsync(new List + { + new DepartmentMember { UserId = "a", DepartmentId = DepartmentId }, + new DepartmentMember { UserId = "b", DepartmentId = DepartmentId } + }); + + var result = await BuildService().GetDispatchUserIdsAsync(DepartmentId); + + result.Should().BeEquivalentTo(new[] { "a", "b" }); + // The open default must not cost a per-user evaluation. + _departmentsService.Verify(x => x.GetDepartmentMemberAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task the_dispatch_audience_skips_disabled_and_deleted_members() + { + GivenPermission(null); + _departmentsService.Setup(x => x.GetAllMembersForDepartmentAsync(DepartmentId)).ReturnsAsync(new List + { + new DepartmentMember { UserId = "active", DepartmentId = DepartmentId }, + new DepartmentMember { UserId = "disabled", DepartmentId = DepartmentId, IsDisabled = true }, + new DepartmentMember { UserId = "deleted", DepartmentId = DepartmentId, IsDeleted = true } + }); + + var result = await BuildService().GetDispatchUserIdsAsync(DepartmentId); + + result.Should().BeEquivalentTo(new[] { "active" }); + } + + [Test] + public async Task a_restricted_department_only_returns_the_authorized_members() + { + var permission = new Permission { DepartmentId = DepartmentId, Action = (int)PermissionActions.DepartmentAdminsOnly }; + GivenPermission(permission); + _departmentsService.Setup(x => x.GetAllMembersForDepartmentAsync(DepartmentId)).ReturnsAsync(new List + { + new DepartmentMember { UserId = "dispatcher", DepartmentId = DepartmentId, IsAdmin = true }, + new DepartmentMember { UserId = "firefighter", DepartmentId = DepartmentId, IsAdmin = false } + }); + + foreach (var id in new[] { "dispatcher", "firefighter" }) + { + var isAdmin = id == "dispatcher"; + _departmentsService.Setup(x => x.GetDepartmentMemberAsync(id, DepartmentId, It.IsAny())) + .ReturnsAsync(new DepartmentMember { UserId = id, DepartmentId = DepartmentId, IsAdmin = isAdmin }); + _personnelRolesService.Setup(x => x.GetRolesForUserAsync(id, DepartmentId)).ReturnsAsync(new List()); + _permissionsService.Setup(x => x.IsUserAllowed(permission, isAdmin, false, It.IsAny>())).Returns(isAdmin); + } + + var result = await BuildService().GetDispatchUserIdsAsync(DepartmentId); + + result.Should().BeEquivalentTo(new[] { "dispatcher" }); + } + } + + /// + /// The commander gate — same shared evaluation as dispatch, different permission. Covers the parts + /// that are specific to it: the right permission is read, and the two gates never share a verdict. + /// + [TestFixture] + public class CommandAccessServiceTests + { + private const int DepartmentId = 1; + private const string UserId = "user-1"; + + private Mock _permissionsService; + private Mock _departmentsService; + private Mock _departmentGroupsService; + private Mock _personnelRolesService; + private Mock _cacheProvider; + private readonly List _cacheKeys = new List(); + + [SetUp] + public void Setup() + { + _permissionsService = new Mock(); + _departmentsService = new Mock(); + _departmentGroupsService = new Mock(); + _personnelRolesService = new Mock(); + _cacheProvider = new Mock(); + _cacheKeys.Clear(); + + _cacheProvider.Setup(x => x.GetStringAsync(It.IsAny())) + .Callback(key => _cacheKeys.Add(key)) + .ReturnsAsync((string)null); + _cacheProvider.Setup(x => x.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(new Department { DepartmentId = DepartmentId, ManagingUserId = "owner" }); + _departmentsService.Setup(x => x.GetDepartmentMemberAsync(UserId, DepartmentId, It.IsAny())) + .ReturnsAsync(new DepartmentMember { UserId = UserId, DepartmentId = DepartmentId, IsAdmin = false }); + _personnelRolesService.Setup(x => x.GetRolesForUserAsync(UserId, DepartmentId)).ReturnsAsync(new List()); + } + + private CommandAccessService BuildService() + => new CommandAccessService( + _permissionsService.Object, + _departmentsService.Object, + _departmentGroupsService.Object, + _personnelRolesService.Object, + _cacheProvider.Object); + + [Test] + public async Task everyone_may_command_when_the_department_has_not_configured_the_permission() + { + _permissionsService.Setup(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.CommandAppLogin)) + .ReturnsAsync((Permission)null); + + var result = await BuildService().CanUseCommandAsync(DepartmentId, UserId); + + result.Should().BeTrue(); + } + + [Test] + public async Task it_reads_the_command_permission_not_the_dispatch_one() + { + var commandPermission = new Permission { DepartmentId = DepartmentId, Action = (int)PermissionActions.DepartmentAdminsOnly }; + _permissionsService.Setup(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.CommandAppLogin)) + .ReturnsAsync(commandPermission); + _permissionsService.Setup(x => x.IsUserAllowed(commandPermission, false, false, It.IsAny>())).Returns(false); + + var result = await BuildService().CanUseCommandAsync(DepartmentId, UserId); + + result.Should().BeFalse(); + _permissionsService.Verify(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.DispatchAppLogin), Times.Never); + } + + [Test] + public async Task its_cache_key_is_distinct_from_the_dispatch_gate() + { + // Sharing a key would let a dispatch verdict answer a command question, and vice versa. + _permissionsService.Setup(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.CommandAppLogin)) + .ReturnsAsync((Permission)null); + + await BuildService().CanUseCommandAsync(DepartmentId, UserId); + + _cacheKeys.Should().Contain(key => key.StartsWith("commandaccess:")); + _cacheKeys.Should().NotContain(key => key.StartsWith("dispatchaccess:")); + } + + [Test] + public async Task an_evaluation_failure_fails_closed() + { + _permissionsService.Setup(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.CommandAppLogin)) + .ThrowsAsync(new Exception("db down")); + + var result = await BuildService().CanUseCommandAsync(DepartmentId, UserId); + + result.Should().BeFalse(); + } + + /// + /// Assisting on a board is stricter than being allowed to command. The permission is open by + /// default, and inferring "therefore every member may move resources on any board" from that open + /// default would hand out authority nobody asked for — so assist requires the department to have + /// deliberately narrowed who commands. + /// + [Test] + public async Task assisting_is_not_granted_off_the_open_default() + { + _permissionsService.Setup(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.CommandAppLogin)) + .ReturnsAsync((Permission)null); + var service = BuildService(); + + // The user may command — the permission is wide open — but that alone grants no board authority. + (await service.CanUseCommandAsync(DepartmentId, UserId)).Should().BeTrue(); + (await service.CanAssistWithCommandAsync(DepartmentId, UserId)).Should().BeFalse(); + } + + [Test] + public async Task assisting_is_not_granted_when_the_permission_is_explicitly_everyone() + { + // An explicit "Everyone" row is the same statement as no row: no opinion about who is trusted. + _permissionsService.Setup(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.CommandAppLogin)) + .ReturnsAsync(new Permission { DepartmentId = DepartmentId, Action = (int)PermissionActions.Everyone }); + + var result = await BuildService().CanAssistWithCommandAsync(DepartmentId, UserId); + + result.Should().BeFalse(); + } + + [Test] + public async Task assisting_is_granted_once_the_department_picks_who_commands() + { + var permission = new Permission { DepartmentId = DepartmentId, Action = (int)PermissionActions.DepartmentAdminsAndSelectRoles, Data = "7" }; + _permissionsService.Setup(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.CommandAppLogin)) + .ReturnsAsync(permission); + _permissionsService.Setup(x => x.IsUserAllowed(permission, false, false, It.IsAny>())).Returns(true); + + var result = await BuildService().CanAssistWithCommandAsync(DepartmentId, UserId); + + result.Should().BeTrue(); + } + + [Test] + public async Task assisting_is_refused_for_someone_the_narrowed_permission_excludes() + { + var permission = new Permission { DepartmentId = DepartmentId, Action = (int)PermissionActions.DepartmentAdminsOnly }; + _permissionsService.Setup(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.CommandAppLogin)) + .ReturnsAsync(permission); + _permissionsService.Setup(x => x.IsUserAllowed(permission, false, false, It.IsAny>())).Returns(false); + + var result = await BuildService().CanAssistWithCommandAsync(DepartmentId, UserId); + + result.Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/RequiresIncidentCapabilityFilterTests.cs b/Tests/Resgrid.Tests/Web/Services/RequiresIncidentCapabilityFilterTests.cs index af8cdadf2..55a558efa 100644 --- a/Tests/Resgrid.Tests/Web/Services/RequiresIncidentCapabilityFilterTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/RequiresIncidentCapabilityFilterTests.cs @@ -195,11 +195,12 @@ public void EstablishCommand_IsNotCapabilityGated_SoBootstrapCanCreateTheCommand private ActionExecutingContext BuildContext( IDictionary args, IDictionary routeValues = null, - bool authenticated = true) + bool authenticated = true, + ICommandAccessService commandAccessService = null) { var httpContext = new DefaultHttpContext { - RequestServices = new StubServiceProvider(_service.Object) + RequestServices = new StubServiceProvider(_service.Object, commandAccessService) }; if (authenticated) @@ -240,16 +241,103 @@ Task Next() private sealed class StubServiceProvider : IServiceProvider { private readonly IIncidentCommandService _service; + private readonly ICommandAccessService _commandAccessService; - public StubServiceProvider(IIncidentCommandService service) + public StubServiceProvider(IIncidentCommandService service, ICommandAccessService commandAccessService = null) { _service = service; + _commandAccessService = commandAccessService; } public object GetService(Type serviceType) - => serviceType == typeof(IIncidentCommandService) ? _service : null; + { + if (serviceType == typeof(IIncidentCommandService)) + return _service; + + // Null when a test doesn't supply one — the filter then skips the commander gate, which is + // what keeps the pre-existing capability tests exercising only capabilities. + if (serviceType == typeof(ICommandAccessService)) + return _commandAccessService; + + return null; + } + } + + private static ICommandAccessService CommandGate(bool allowed) + { + var mock = new Mock(); + mock.Setup(x => x.CanUseCommandAsync(It.IsAny(), It.IsAny())).ReturnsAsync(allowed); + return mock.Object; } #endregion Helpers + + #region Commander permission gate + + [Test] + public async Task Returns403_WhenTheDepartmentHasNotAuthorizedTheUserAsACommander() + { + // The capability check never even runs: a member the department hasn't authorized has no + // business on the board surface, whatever ICS role happens to be recorded against them. + _service.Setup(s => s.GetCommandByIdAsync("cmd-1")) + .ReturnsAsync(new IncidentCommand { CallId = 5, DepartmentId = DepartmentId }); + _service.Setup(s => s.GetCapabilitiesForUserAsync(DepartmentId, 5, UserId)).ReturnsAsync(IncidentCapabilities.All); + + var filter = new RequiresIncidentCapabilityAttribute(IncidentCapabilities.AssignResources); + var context = BuildContext( + args: new Dictionary { ["node"] = new CommandStructureNode { IncidentCommandId = "cmd-1" } }, + commandAccessService: CommandGate(false)); + + var nextCalled = await Invoke(filter, context); + + nextCalled.Should().BeFalse(); + context.Result.Should().BeOfType() + .Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + _service.Verify(s => s.GetCapabilitiesForUserAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task AllowsRequest_WhenAuthorizedAsACommanderAndHoldingTheCapability() + { + // A dispatcher granted the command permission assists through the same path: the gate passes, + // then the assist capability set carries the action. + _service.Setup(s => s.GetCommandByIdAsync("cmd-1")) + .ReturnsAsync(new IncidentCommand { CallId = 5, DepartmentId = DepartmentId }); + _service.Setup(s => s.GetCapabilitiesForUserAsync(DepartmentId, 5, UserId)) + .ReturnsAsync(IncidentRoleCapabilityMap.CommandAssistCapabilities); + + var filter = new RequiresIncidentCapabilityAttribute(IncidentCapabilities.AssignResources); + var context = BuildContext( + args: new Dictionary { ["node"] = new CommandStructureNode { IncidentCommandId = "cmd-1" } }, + commandAccessService: CommandGate(true)); + + var nextCalled = await Invoke(filter, context); + + nextCalled.Should().BeTrue(); + context.Result.Should().BeNull(); + } + + [Test] + public async Task TheAssistSetCoversWhatDispatchNeedsAndNothingMore() + { + await Task.CompletedTask; + var assist = IncidentRoleCapabilityMap.CommandAssistCapabilities; + + // What a dispatcher assisting an incident actually does. + assist.Should().HaveFlag(IncidentCapabilities.ViewBoard); + assist.Should().HaveFlag(IncidentCapabilities.AssignResources); + assist.Should().HaveFlag(IncidentCapabilities.ManageResources); + assist.Should().HaveFlag(IncidentCapabilities.ManageTimers); + assist.Should().HaveFlag(IncidentCapabilities.ManageAccountability); + + // Assisting is not commanding: the shape of the incident and its lifecycle stay with whoever + // actually holds command. + assist.Should().NotHaveFlag(IncidentCapabilities.ManageCommand); + assist.Should().NotHaveFlag(IncidentCapabilities.ManageStructure); + assist.Should().NotHaveFlag(IncidentCapabilities.ManagePublicInformation); + assist.Should().NotHaveFlag(IncidentCapabilities.ManageDocuments); + } + + #endregion Commander permission gate } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs index 7cd2ed47b..c1c8d8325 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs @@ -30,12 +30,30 @@ public class IncidentCommandController : V4AuthenticatedApiControllerbase private readonly IIncidentCommandService _incidentCommandService; private readonly IIncidentCommandNotificationService _incidentCommandNotificationService; + private readonly ICommandAccessService _commandAccessService; + public IncidentCommandController(IIncidentCommandService incidentCommandService, - IIncidentCommandNotificationService incidentCommandNotificationService) + IIncidentCommandNotificationService incidentCommandNotificationService, + ICommandAccessService commandAccessService) { _incidentCommandService = incidentCommandService; _incidentCommandNotificationService = incidentCommandNotificationService; + _commandAccessService = commandAccessService; } + + /// + /// Whether the caller may act as a commander (CommandAppLogin). Gates establishing command. + /// The Command_View / Command_Create policies above are plan-level claims; this is the + /// department's own choice about which of its members command incidents. + /// + private Task CanCommandAsync() => _commandAccessService.CanUseCommandAsync(DepartmentId, UserId); + + /// + /// Whether the caller may READ command boards — the same commander gate as everything else on this + /// surface. A dispatcher who needs to work boards is given the command permission too (it grants + /// the assist capability set); dispatch authorization on its own is not a way in. + /// + private Task CanReadBoardsAsync() => CanCommandAsync(); #endregion Members and Constructors #region Command lifecycle @@ -53,6 +71,9 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService, if (input == null || input.CallId <= 0) return BadRequest(); + if (!await CanCommandAsync()) + return Unauthorized(); + var result = new ICModels.IncidentCommandResult(); var command = await _incidentCommandService.EstablishCommandAsync(DepartmentId, input.CallId, UserId, input.CommandDefinitionId, CancellationToken.None); @@ -143,6 +164,9 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService, [Authorize(Policy = ResgridResources.Command_View)] public async Task> GetCommandList([FromQuery] bool includeClosed = false) { + if (!await CanReadBoardsAsync()) + return Unauthorized(); + var summaries = await _incidentCommandService.GetCommandSummariesForDepartmentAsync(DepartmentId, includeClosed); var result = new ICModels.IncidentCommandSummariesResult { @@ -164,6 +188,9 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService, [Authorize(Policy = ResgridResources.Command_View)] public async Task> GetCommandBoardById(string incidentCommandId) { + if (!await CanReadBoardsAsync()) + return Unauthorized(); + var result = new ICModels.IncidentCommandBoardResult(); var board = await _incidentCommandService.GetCommandBoardByIdAsync(DepartmentId, incidentCommandId); @@ -187,6 +214,9 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService, [Authorize(Policy = ResgridResources.Command_View)] public async Task> GetCommandBoard(int callId) { + if (!await CanReadBoardsAsync()) + return Unauthorized(); + var result = new ICModels.IncidentCommandBoardResult(); var board = await _incidentCommandService.GetCommandBoardAsync(DepartmentId, callId); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/SecurityController.cs b/Web/Resgrid.Web.Services/Controllers/v4/SecurityController.cs index 5e9779b9a..e18851bfe 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/SecurityController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/SecurityController.cs @@ -97,6 +97,8 @@ public async Task> GetCurrentUsersRights() var createWorkflowPermission = await _permissionsService.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.CreateWorkflow); var manageWorkflowCredentialPermission = await _permissionsService.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.ManageWorkflowCredentials); var viewWorkflowRunsPermission = await _permissionsService.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.ViewWorkflowRuns); + var dispatchAppLoginPermission = await _permissionsService.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.DispatchAppLogin); + var commandAppLoginPermission = await _permissionsService.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.CommandAppLogin); result.Data.CanViewPII = _permissionsService.IsUserAllowed(viewPIIPermission, result.Data.IsAdmin, isGroupAdmin, roles); result.Data.CanCreateCalls = _permissionsService.IsUserAllowed(createCallPermission, result.Data.IsAdmin, isGroupAdmin, roles); @@ -105,6 +107,8 @@ public async Task> GetCurrentUsersRights() result.Data.CanCreateWorkflow = _permissionsService.IsUserAllowed(createWorkflowPermission, result.Data.IsAdmin, isGroupAdmin, roles); result.Data.CanManageWorkflowCredentials = _permissionsService.IsUserAllowed(manageWorkflowCredentialPermission, result.Data.IsAdmin, isGroupAdmin, roles); result.Data.CanViewWorkflowRuns = _permissionsService.IsUserAllowed(viewWorkflowRunsPermission, result.Data.IsAdmin, isGroupAdmin, roles); + result.Data.CanLoginToDispatchApp = _permissionsService.IsUserAllowed(dispatchAppLoginPermission, result.Data.IsAdmin, isGroupAdmin, roles); + result.Data.CanLoginToCommandApp = _permissionsService.IsUserAllowed(commandAppLoginPermission, result.Data.IsAdmin, isGroupAdmin, roles); var novuSuccess = await _novuProvider.CreateUserSubscriber(UserId, department.Code, DepartmentId, profile.MembershipEmail, profile.FirstName, profile.LastName); diff --git a/Web/Resgrid.Web.Services/Filters/RequiresIncidentCapabilityAttribute.cs b/Web/Resgrid.Web.Services/Filters/RequiresIncidentCapabilityAttribute.cs index a3b0a5339..4f1dee3f2 100644 --- a/Web/Resgrid.Web.Services/Filters/RequiresIncidentCapabilityAttribute.cs +++ b/Web/Resgrid.Web.Services/Filters/RequiresIncidentCapabilityAttribute.cs @@ -56,6 +56,19 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE return; } + // The department's own gate on who may work a command board at all. Checked before capabilities + // because a member the department hasn't authorized as a commander shouldn't reach the board + // surface whatever ICS role happens to be recorded against them. + var commandAccess = context.HttpContext.RequestServices?.GetService(typeof(ICommandAccessService)) as ICommandAccessService; + if (commandAccess != null && !await commandAccess.CanUseCommandAsync(departmentId, userId)) + { + context.Result = new ObjectResult("You are not authorized to work incident command for this department.") + { + StatusCode = StatusCodes.Status403Forbidden + }; + return; + } + var callId = await ResolveCallIdAsync(context, service, departmentId); if (callId == null || callId.Value <= 0) { diff --git a/Web/Resgrid.Web.Services/Models/v4/Security/DepartmentRightsResult.cs b/Web/Resgrid.Web.Services/Models/v4/Security/DepartmentRightsResult.cs index 05e555515..fa56b8031 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Security/DepartmentRightsResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Security/DepartmentRightsResult.cs @@ -78,6 +78,18 @@ public class DepartmentRightsResultData /// public bool CanCreateWorkflow { get; set; } + /// + /// Can the user sign in to the Dispatch app. Dispatch exposes private command, unit and responder + /// traffic, so departments can restrict it to admins or selected roles; defaults to everyone. + /// + public bool CanLoginToDispatchApp { get; set; } + + /// + /// Can the user act as a commander: sign in to the IC app, establish command, and read command + /// boards. Restrictable to admins or selected roles; defaults to everyone. + /// + public bool CanLoginToCommandApp { get; set; } + /// /// Can the user manage workflow credentials /// diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 6eb511bd9..fa5c64430 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1480,6 +1480,20 @@ resources, manage objectives, timers, map annotations, and read the action timeline for a Call. + + + Whether the caller may act as a commander (CommandAppLogin). Gates establishing command. + The Command_View / Command_Create policies above are plan-level claims; this is the + department's own choice about which of its members command incidents. + + + + + Whether the caller may READ command boards — the same commander gate as everything else on this + surface. A dispatcher who needs to work boards is given the command permission too (it grants + the assist capability set); dispatch authorization on its own is not a way in. + + Establishes command on a call, optionally seeding lanes from a command definition. @@ -4537,6 +4551,52 @@ Is the user a group admin + + + UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a + function that is setting status for the current user. + + + + + The state/staffing level of the user to set for the user. + + + + + Note for the staffing level + + + + + The result object for a state/staffing level request. + + + + + The UserId GUID/UUID for the user state/staffing level being return + + + + + The full name of the user for the state/staffing level being returned + + + + + The current staffing level (state) type for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Staffing note for the User's staffing + + Input data to add a staffing schedule in the Resgrid system @@ -4642,52 +4702,6 @@ Note for this staffing schedule - - - UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a - function that is setting status for the current user. - - - - - The state/staffing level of the user to set for the user. - - - - - Note for the staffing level - - - - - The result object for a state/staffing level request. - - - - - The UserId GUID/UUID for the user state/staffing level being return - - - - - The full name of the user for the state/staffing level being returned - - - - - The current staffing level (state) type for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Staffing note for the User's staffing - - A resrouce in the system this could be a user or unit @@ -10192,202 +10206,372 @@ Identifier of the new npte - + - A GPS location for a point in time of a specificed person + The result of getting all personnel filters for the system - + - PersonId of the person that the location is for + The Id value of the filter - + - The timestamp of the location in UTC + The type of the filter - + - GPS Latitude of the Person + The filters name - + - GPS Longitude of the Person + Result containing all the data required to populate the New Call form - + - GPS Latitude\Longitude Accuracy of the Person + Response Data - + - GPS Altitude of the Person + Result that contains all the options available to filter personnel against compatible Resgrid APIs - + - GPS Altitude Accuracy of the Person + Response Data - + - GPS Speed of the Person + Result containing all the data required to populate the New Call form - + - GPS Heading of the Person + Response Data - + - A unit location in the Resgrid system + Information about a User - + - Response Data + The UserId GUID/UUID for the user - + - The information about a specific unit's location + DepartmentId of the deparment the user belongs to - + - Id of the Person + Department specificed ID number for this user - + - The Timestamp for the location in UTC + The Users First Name - + - GPS Latitude of the Person + The Users Last Name - + - GPS Longitude of the Person + The Users Email Address - + - GPS Latitude\Longitude Accuracy of the Person + The Users Mobile Telephone Number - + - GPS Altitude of the Person + GroupId the user is assigned to (0 for no group) - + - GPS Altitude Accuracy of the Person + Name of the group the user is assigned to - + - GPS Speed of the Person + Enumeration/List of roles the user currently holds - + - GPS Heading of the Person + The current action/status type for the user - + - The result of getting the current staffing for a user + The current action/status string for the user - + - Response Data + The current action/status color hex string for the user - + - Information about a User staffing + The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. - + - The UserId GUID/UUID for the user status being return + The current action/status destination id for the user - + - DepartmentId of the deparment the user belongs to + The current action/status destination name for the user - + - The current staffing type for the user + The current staffing level (state) type for the user - + - The timestamp of the last staffing. This is converted UTC version of the timestamp. + The current staffing level (state) string for the user - + - The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. + The current staffing level (state) color hex string for the user - + - Note for this staffing + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - + - Saves (sets) and Personnel Staffing in the system, for a single user + Users last known location - + - UnitId of the apparatus that the state is being set for + Sorting weight for the user - + - The UnitStateType of the Unit + User Defined Field values for this personnel record - + - The timestamp of the status event in UTC + A GPS location for a point in time of a specificed person - + - The timestamp of the status event in the local time of the device + PersonId of the person that the location is for - + - User provided note for this event + The timestamp of the location in UTC - + - The event id used for queuing on mobile applications + GPS Latitude of the Person - + - Depicts a result after saving a person status + GPS Longitude of the Person - + + + GPS Latitude\Longitude Accuracy of the Person + + + + + GPS Altitude of the Person + + + + + GPS Altitude Accuracy of the Person + + + + + GPS Speed of the Person + + + + + GPS Heading of the Person + + + + + A unit location in the Resgrid system + + + + + Response Data + + + + + The information about a specific unit's location + + + + + Id of the Person + + + + + The Timestamp for the location in UTC + + + + + GPS Latitude of the Person + + + + + GPS Longitude of the Person + + + + + GPS Latitude\Longitude Accuracy of the Person + + + + + GPS Altitude of the Person + + + + + GPS Altitude Accuracy of the Person + + + + + GPS Speed of the Person + + + + + GPS Heading of the Person + + + + + The result of getting the current staffing for a user + + + + + Response Data + + + + + Information about a User staffing + + + + + The UserId GUID/UUID for the user status being return + + + + + DepartmentId of the deparment the user belongs to + + + + + The current staffing type for the user + + + + + The timestamp of the last staffing. This is converted UTC version of the timestamp. + + + + + The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. + + + + + Note for this staffing + + + + + Saves (sets) and Personnel Staffing in the system, for a single user + + + + + UnitId of the apparatus that the state is being set for + + + + + The UnitStateType of the Unit + + + + + The timestamp of the status event in UTC + + + + + The timestamp of the status event in the local time of the device + + + + + User provided note for this event + + + + + The event id used for queuing on mobile applications + + + + + Depicts a result after saving a person status + + + Response Data @@ -10695,279 +10879,109 @@ Response Data - + - The result of getting all personnel filters for the system + Result containing all the data required to populate the New Call form - + - The Id value of the filter + Response Data - + - The type of the filter + Details of a protocol - + - The filters name + Protocol id - + - Result containing all the data required to populate the New Call form + Department id - + - Response Data + Name of the Protocol - + - Result that contains all the options available to filter personnel against compatible Resgrid APIs + Protocol code - + - Response Data + This this protocol disabled - + - Result containing all the data required to populate the New Call form + Protocol description - + - Response Data + Text of the protocol - + - Information about a User + UTC date and time when the Protocol was created - + - The UserId GUID/UUID for the user + UserId of the user who created the protocol - + - DepartmentId of the deparment the user belongs to + UTC timestamp of when the Protocol was updated - + - Department specificed ID number for this user + Minimum triggering Weight of the Protocol - + - The Users First Name + UserId that last updated the Protocol - + - The Users Last Name + Triggers used to activate this Protocol - + - The Users Email Address + Attachments for this Protocol - + - The Users Mobile Telephone Number + Questions used to determine if this Protocol needs to be used or not - + - GroupId the user is assigned to (0 for no group) + State type - + - Name of the group the user is assigned to + Result containing all the data required to populate the New Call form - + - Enumeration/List of roles the user currently holds - - - - - The current action/status type for the user - - - - - The current action/status string for the user - - - - - The current action/status color hex string for the user - - - - - The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. - - - - - The current action/status destination id for the user - - - - - The current action/status destination name for the user - - - - - The current staffing level (state) type for the user - - - - - The current staffing level (state) string for the user - - - - - The current staffing level (state) color hex string for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Users last known location - - - - - Sorting weight for the user - - - - - User Defined Field values for this personnel record - - - - - Result containing all the data required to populate the New Call form - - - - - Response Data - - - - - Details of a protocol - - - - - Protocol id - - - - - Department id - - - - - Name of the Protocol - - - - - Protocol code - - - - - This this protocol disabled - - - - - Protocol description - - - - - Text of the protocol - - - - - UTC date and time when the Protocol was created - - - - - UserId of the user who created the protocol - - - - - UTC timestamp of when the Protocol was updated - - - - - Minimum triggering Weight of the Protocol - - - - - UserId that last updated the Protocol - - - - - Triggers used to activate this Protocol - - - - - Attachments for this Protocol - - - - - Questions used to determine if this Protocol needs to be used or not - - - - - State type - - - - - Result containing all the data required to populate the New Call form - - - - - Response Data + Response Data @@ -11114,6 +11128,18 @@ Can the user create/edit workflows + + + Can the user sign in to the Dispatch app. Dispatch exposes private command, unit and responder + traffic, so departments can restrict it to admins or selected roles; defaults to everyone. + + + + + Can the user act as a commander: sign in to the IC app, establish command, and read command + boards. Restrictable to admins or selected roles; defaults to everyone. + + Can the user manage workflow credentials @@ -12236,545 +12262,545 @@ Default constructor - + - Depicts a result after saving a unit status + Result that contains all the options available to filter units against compatible Resgrid APIs - + Response Data - + - Object inputs for setting a users Status/Action. If this object is used in an operation that sets - a status for the current user the UserId value in this object will be ignored. + A unit in the Resgrid system - + - UnitId of the apparatus that the state is being set for + Response Data - + - The UnitStateType of the Unit + The information about a specific unit - + - The Call/Station the unit is responding to + Id of the Unit - + - Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + The Id of the department the unit is under - + - The timestamp of the status event in UTC + Name of the Unit - + - The timestamp of the status event in the local time of the device + Department assigned type for the unit - + - User provided note for this event + Department assigned type id for the unit - + - GPS Latitude of the Unit + Custom Statuses Set Id - + - GPS Longitude of the Unit + Station Id of the station housing the unit (0 means no station) - + - GPS Latitude\Longitude Accuracy of the Unit + Name of the station the unit is under - + - GPS Altitude of the Unit + Vehicle Identification Number for the unit - + - GPS Altitude Accuracy of the Unit + Plate Number for the Unit - + - GPS Speed of the Unit + Is the unit 4-Wheel drive - + - GPS Heading of the Unit + Does the unit require a special permit to drive - + - The event id used for queuing on mobile applications + Id number of the units current destionation (0 means no destination) - + - The accountability roles filed for this event + The current status/state of the Unit - + - Role filled by a User on a Unit for an event + The Timestamp of the status - + - Id of the locally stored event + The units current Latitude - + - Local Event Id + The units current Longitude - + - UserId of the user filling the role + Current user provide status note - + - RoleId of the role being filled + User Defined Field values for this unit - + - The name of the Role + Unit role information for roles on a unit - + - Depicts a unit status in the Resgrid system. + Unit Role Id - + - Response Data + User Id of the user in the role (could be null) - + - Depicts a unit's status + Name of the Role - + - Unit Id + Name of the user in the role (could be null) - + - Units Name + Multiple Unit infos Result - + - The Type of the Unit + Response Data - + - Units current Status (State) + Default constructor - + - CSS for status (for display) + The information about a specific unit - + - CSS Style for status (for display) + Id of the Unit - + - Timestamp of this Unit State + The Id of the department the unit is under - + - Timestamp in Utc of this Unit State + Name of the Unit - + - Destination Id (Station or Call) + Department assigned type for the unit - + - Destination type (Station, Call, or POI). + Department assigned type id for the unit - + - Name of the Desination (Call or Station) + Custom Statuses Set Id - + - Destination address. + Station Id of the station housing the unit (0 means no station) - + - Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not - suitable for programmatic branching; use as the - machine-readable discriminator instead. + Name of the station the unit is under - + - Note for the State + Vehicle Identification Number for the unit - + - Latitude + Plate Number for the Unit - + - Longitude + Is the unit 4-Wheel drive - + - Name of the Group the Unit is in + Does the unit require a special permit to drive - + - Id of the Group the Unit is in + Id number of the units current destination (0 means no destination) - + - Unit statuses (states) + Name of the units current destination (0 means no destination) - + - Response Data + The current status/state of the Unit - + - Default constructor + The current status/state of the Unit as a name - + - Result that contains all the options available to filter units against compatible Resgrid APIs + The current status/state of the Unit color - + - Response Data + The Timestamp of the status - + - A unit in the Resgrid system + The Timestamp of the status in UTC/GMT - + - Response Data + The units current Latitude - + - The information about a specific unit + The units current Longitude - + - Id of the Unit + Current user provide status note - + - The Id of the department the unit is under + Units Roles - + - Name of the Unit + Multiple Units Result - + - Department assigned type for the unit + Response Data - + - Department assigned type id for the unit + Default constructor - + - Custom Statuses Set Id + Depicts a result after saving a unit status - + - Station Id of the station housing the unit (0 means no station) + Response Data - + - Name of the station the unit is under + Object inputs for setting a users Status/Action. If this object is used in an operation that sets + a status for the current user the UserId value in this object will be ignored. - + - Vehicle Identification Number for the unit + UnitId of the apparatus that the state is being set for - + - Plate Number for the Unit + The UnitStateType of the Unit - + - Is the unit 4-Wheel drive + The Call/Station the unit is responding to - + - Does the unit require a special permit to drive + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). - + - Id number of the units current destionation (0 means no destination) + The timestamp of the status event in UTC - + - The current status/state of the Unit + The timestamp of the status event in the local time of the device - + - The Timestamp of the status + User provided note for this event - + - The units current Latitude + GPS Latitude of the Unit - + - The units current Longitude + GPS Longitude of the Unit - + - Current user provide status note + GPS Latitude\Longitude Accuracy of the Unit - + - User Defined Field values for this unit + GPS Altitude of the Unit - + - Unit role information for roles on a unit + GPS Altitude Accuracy of the Unit - + - Unit Role Id + GPS Speed of the Unit - + - User Id of the user in the role (could be null) + GPS Heading of the Unit - + - Name of the Role + The event id used for queuing on mobile applications - + - Name of the user in the role (could be null) + The accountability roles filed for this event - + - Multiple Unit infos Result + Role filled by a User on a Unit for an event - + - Response Data + Id of the locally stored event - + - Default constructor + Local Event Id - + - The information about a specific unit + UserId of the user filling the role - + - Id of the Unit + RoleId of the role being filled - + - The Id of the department the unit is under + The name of the Role - + - Name of the Unit + Depicts a unit status in the Resgrid system. - + - Department assigned type for the unit + Response Data - + - Department assigned type id for the unit + Depicts a unit's status - + - Custom Statuses Set Id + Unit Id - + - Station Id of the station housing the unit (0 means no station) + Units Name - + - Name of the station the unit is under + The Type of the Unit - + - Vehicle Identification Number for the unit + Units current Status (State) - + - Plate Number for the Unit + CSS for status (for display) - + - Is the unit 4-Wheel drive + CSS Style for status (for display) - + - Does the unit require a special permit to drive + Timestamp of this Unit State - + - Id number of the units current destination (0 means no destination) + Timestamp in Utc of this Unit State - + - Name of the units current destination (0 means no destination) + Destination Id (Station or Call) - + - The current status/state of the Unit + Destination type (Station, Call, or POI). - + - The current status/state of the Unit as a name + Name of the Desination (Call or Station) - + - The current status/state of the Unit color + Destination address. - + - The Timestamp of the status + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. - + - The Timestamp of the status in UTC/GMT + Note for the State - + - The units current Latitude + Latitude - + - The units current Longitude + Longitude - + - Current user provide status note + Name of the Group the Unit is in - + - Units Roles + Id of the Group the Unit is in - + - Multiple Units Result + Unit statuses (states) - + Response Data - + Default constructor diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs index b07361c53..dd1979e1c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs @@ -394,6 +394,32 @@ public async Task Index() useCalendarSyncPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); model.UseCalendarSyncPermissions = new SelectList(useCalendarSyncPermissions, "Id", "Name"); + // Dispatch app login: defaults to Everyone so departments that never configure it are unaffected. + if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.DispatchAppLogin)) + model.DispatchAppLogin = permissions.First(x => x.PermissionType == (int)PermissionTypes.DispatchAppLogin).Action; + else + model.DispatchAppLogin = 3; + + var dispatchAppLoginPermissions = new List(); + dispatchAppLoginPermissions.Add(new { Id = 3, Name = "Everyone" }); + dispatchAppLoginPermissions.Add(new { Id = 0, Name = "Department Admins" }); + dispatchAppLoginPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); + dispatchAppLoginPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + model.DispatchAppLoginPermissions = new SelectList(dispatchAppLoginPermissions, "Id", "Name"); + + // Commander access: defaults to Everyone so departments that never configure it are unaffected. + if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.CommandAppLogin)) + model.CommandAppLogin = permissions.First(x => x.PermissionType == (int)PermissionTypes.CommandAppLogin).Action; + else + model.CommandAppLogin = 3; + + var commandAppLoginPermissions = new List(); + commandAppLoginPermissions.Add(new { Id = 3, Name = "Everyone" }); + commandAppLoginPermissions.Add(new { Id = 0, Name = "Department Admins" }); + commandAppLoginPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); + commandAppLoginPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + model.CommandAppLoginPermissions = new SelectList(commandAppLoginPermissions, "Id", "Name"); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); model.IsManagingUser = department.ManagingUserId == UserId; diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/PermissionsView.cs b/Web/Resgrid.Web/Areas/User/Models/Security/PermissionsView.cs index 684662ce2..e8120f625 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Security/PermissionsView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Security/PermissionsView.cs @@ -94,6 +94,12 @@ public class PermissionsView public int UseCalendarSync { get; set; } public SelectList UseCalendarSyncPermissions { get; set; } + public int DispatchAppLogin { get; set; } + public SelectList DispatchAppLoginPermissions { get; set; } + + public int CommandAppLogin { get; set; } + public SelectList CommandAppLoginPermissions { get; set; } + // Two-Factor Authentication enforcement public int Require2FAForAdmins { get; set; } public SelectList Require2FAForAdminsOptions { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml index 5e07c43c6..389501f3f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml @@ -323,6 +323,26 @@ + + Dispatch App Login + Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. + @Html.DropDownListFor(m => m.DispatchAppLogin, Model.DispatchAppLoginPermissions) + @localizer["PermissionNA"] + + @localizer["PermissionNoRoles"] + + + + + Command App Login + Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. + @Html.DropDownListFor(m => m.CommandAppLogin, Model.CommandAppLoginPermissions) + @localizer["PermissionNA"] + + @localizer["PermissionNoRoles"] + + + diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.permissions.js b/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.permissions.js index 04c166e52..b6dee7ea6 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.permissions.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.permissions.js @@ -782,6 +782,60 @@ var resgrid; initPermRoles("#calSyncRoles", 28); //////////////////////////////////////////////////////// + // Dispatch App Login + //////////////////////////////////////////////////////// + $('#DispatchAppLogin').change(function () { + var val = this.value; + $.ajax({ + url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=29&perm=' + val, + type: 'GET' + }).done(function (results) { + }); + if ($("#DispatchAppLogin").val() === "2") { + $('#dispatchAppLoginNoRolesSpan').hide(); + $('#dispatchAppLoginRolesDiv').show(); + } else { + $('#dispatchAppLoginNoRolesSpan').show(); + $('#dispatchAppLoginRolesDiv').hide(); + } + }); + if ($("#DispatchAppLogin").val() === "2") { + $('#dispatchAppLoginNoRolesSpan').hide(); + $('#dispatchAppLoginRolesDiv').show(); + } else { + $('#dispatchAppLoginNoRolesSpan').show(); + $('#dispatchAppLoginRolesDiv').hide(); + } + initPermRoles("#dispatchAppLoginRoles", 29); + //////////////////////////////////////////////////////// + + // Command App Login + //////////////////////////////////////////////////////// + $('#CommandAppLogin').change(function () { + var val = this.value; + $.ajax({ + url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=30&perm=' + val, + type: 'GET' + }).done(function (results) { + }); + if ($("#CommandAppLogin").val() === "2") { + $('#commandAppLoginNoRolesSpan').hide(); + $('#commandAppLoginRolesDiv').show(); + } else { + $('#commandAppLoginNoRolesSpan').show(); + $('#commandAppLoginRolesDiv').hide(); + } + }); + if ($("#CommandAppLogin").val() === "2") { + $('#commandAppLoginNoRolesSpan').hide(); + $('#commandAppLoginRolesDiv').show(); + } else { + $('#commandAppLoginNoRolesSpan').show(); + $('#commandAppLoginRolesDiv').hide(); + } + initPermRoles("#commandAppLoginRoles", 30); + //////////////////////////////////////////////////////// + }); })(permissions = security.permissions || (security.permissions = {})); })(security = resgrid.security || (resgrid.security = {})); From 946e92efe29610ecd1c14b1695179cb4c18064bc Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 10 Aug 2026 16:57:31 -0700 Subject: [PATCH 3/6] RG-T121 Fix notification issue, bo support for deletions --- Core/Resgrid.Model/Events/UnitStatusEvent.cs | 6 + .../Resgrid.Model/Events/UserStaffingEvent.cs | 6 + Core/Resgrid.Model/Events/UserStatusEvent.cs | 6 + .../Repositories/ISystemAuditsRepository.cs | 12 + .../Services/IActionLogsService.cs | 2 +- Core/Resgrid.Model/Services/IUnitsService.cs | 3 +- .../Services/IUserStateService.cs | 3 +- Core/Resgrid.Services/ActionLogsService.cs | 12 +- .../CallDispatchStatusService.cs | 2 +- Core/Resgrid.Services/UnitsService.cs | 4 +- Core/Resgrid.Services/UserStateService.cs | 4 +- .../OutboundEventProvider.cs | 34 +- .../SelectSystemAuditsByTypePagedQuery.cs | 23 + .../SystemAuditRepository.cs | 47 + .../CallDispatchStatusServiceTests.cs | 22 +- .../Resgrid.Web.Services.xml | 1116 ++++++++--------- .../Logic/StaffingScheduleLogic.cs | 4 +- 17 files changed, 719 insertions(+), 587 deletions(-) create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByTypePagedQuery.cs diff --git a/Core/Resgrid.Model/Events/UnitStatusEvent.cs b/Core/Resgrid.Model/Events/UnitStatusEvent.cs index bbb8a2504..97649dfc6 100644 --- a/Core/Resgrid.Model/Events/UnitStatusEvent.cs +++ b/Core/Resgrid.Model/Events/UnitStatusEvent.cs @@ -5,5 +5,11 @@ public class UnitStatusEvent public int DepartmentId { get; set; } public UnitState Status { get; set; } public UnitState PreviousStatus { get; set; } + + /// + /// True when the status change was made by an automated process (i.e. call dispatch auto-status) + /// and not directly by a user. Automated changes should not generate user notifications. + /// + public bool AutoGenerated { get; set; } } } \ No newline at end of file diff --git a/Core/Resgrid.Model/Events/UserStaffingEvent.cs b/Core/Resgrid.Model/Events/UserStaffingEvent.cs index 528a546d5..12a262ffa 100644 --- a/Core/Resgrid.Model/Events/UserStaffingEvent.cs +++ b/Core/Resgrid.Model/Events/UserStaffingEvent.cs @@ -5,5 +5,11 @@ public class UserStaffingEvent public int DepartmentId { get; set; } public UserState Staffing { get; set; } public UserState PreviousStaffing { get; set; } + + /// + /// True when the staffing change was made by an automated process (i.e. scheduled department reset) + /// and not directly by a user. Automated changes should not generate user notifications. + /// + public bool AutoGenerated { get; set; } } } \ No newline at end of file diff --git a/Core/Resgrid.Model/Events/UserStatusEvent.cs b/Core/Resgrid.Model/Events/UserStatusEvent.cs index 962848ef1..f8e0eb71e 100644 --- a/Core/Resgrid.Model/Events/UserStatusEvent.cs +++ b/Core/Resgrid.Model/Events/UserStatusEvent.cs @@ -5,5 +5,11 @@ public class UserStatusEvent public int DepartmentId { get; set; } public ActionLog PreviousStatus { get; set; } public ActionLog Status { get; set; } + + /// + /// True when the status change was made by an automated process (i.e. scheduled department reset) + /// and not directly by a user. Automated changes should not generate user notifications. + /// + public bool AutoGenerated { get; set; } } } \ No newline at end of file diff --git a/Core/Resgrid.Model/Repositories/ISystemAuditsRepository.cs b/Core/Resgrid.Model/Repositories/ISystemAuditsRepository.cs index 127a49417..e9af9670b 100644 --- a/Core/Resgrid.Model/Repositories/ISystemAuditsRepository.cs +++ b/Core/Resgrid.Model/Repositories/ISystemAuditsRepository.cs @@ -32,5 +32,17 @@ public interface ISystemAuditsRepository : IRepository /// Page size. /// Task<IEnumerable<SystemAudit>>. Task> GetByDepartmentIdPagedAsync(int departmentId, DateTime startDate, DateTime endDate, int page, int pageSize); + + /// + /// Gets a date-ranged, paged set of system audits of a single type across all users and + /// departments (e.g. every account-deletion request platform-wide). + /// + /// The value (stored as an int). + /// Inclusive lower bound on LoggedOn (UTC). + /// Exclusive upper bound on LoggedOn (UTC). + /// 1-based page number. + /// Page size. + /// Task<IEnumerable<SystemAudit>>. + Task> GetByTypePagedAsync(int type, DateTime startDate, DateTime endDate, int page, int pageSize); } } diff --git a/Core/Resgrid.Model/Services/IActionLogsService.cs b/Core/Resgrid.Model/Services/IActionLogsService.cs index f3cfef8ee..99b7ab3d8 100644 --- a/Core/Resgrid.Model/Services/IActionLogsService.cs +++ b/Core/Resgrid.Model/Services/IActionLogsService.cs @@ -85,7 +85,7 @@ public interface IActionLogsService /// The action logs. /// The cancellation token that can be used by other objects or threads to receive notice of cancellation. /// Task<System.Boolean>. - Task SaveAllActionLogsAsync(List actionLogs, CancellationToken cancellationToken = default(CancellationToken)); + Task SaveAllActionLogsAsync(List actionLogs, CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false); /// /// Sets the user action asynchronous. diff --git a/Core/Resgrid.Model/Services/IUnitsService.cs b/Core/Resgrid.Model/Services/IUnitsService.cs index e98d3031f..e9c7789b1 100644 --- a/Core/Resgrid.Model/Services/IUnitsService.cs +++ b/Core/Resgrid.Model/Services/IUnitsService.cs @@ -184,9 +184,10 @@ Task SetUnitStateAsync(int unitId, int unitStateType, int departmentI /// The state. /// The department identifier. /// The cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// True when the status change is from an automated process (i.e. call dispatch auto-status) and should not generate user notifications. /// Task<UnitState>. Task SetUnitStateAsync(UnitState state, int departmentId, - CancellationToken cancellationToken = default(CancellationToken)); + CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false); /// /// Gets the logs for unit asynchronous. diff --git a/Core/Resgrid.Model/Services/IUserStateService.cs b/Core/Resgrid.Model/Services/IUserStateService.cs index 5140a18f9..316526aab 100644 --- a/Core/Resgrid.Model/Services/IUserStateService.cs +++ b/Core/Resgrid.Model/Services/IUserStateService.cs @@ -51,9 +51,10 @@ Task CreateUserState(string userId, int departmentId, int userStateTy /// Type of the user state. /// The note. /// The cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// True when the staffing change is from an automated process (i.e. scheduled reset) and should not generate user notifications. /// Task<UserState>. Task CreateUserState(string userId, int departmentId, int userStateType, string note, - CancellationToken cancellationToken = default(CancellationToken)); + CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false); /// /// Creates the user state asynchronous. diff --git a/Core/Resgrid.Services/ActionLogsService.cs b/Core/Resgrid.Services/ActionLogsService.cs index b2341a772..68c5c2890 100644 --- a/Core/Resgrid.Services/ActionLogsService.cs +++ b/Core/Resgrid.Services/ActionLogsService.cs @@ -204,7 +204,7 @@ public async Task GetPreviousActionLogAsync(string userId, int action return actionLog; } - public async Task SaveAllActionLogsAsync(List actionLogs, CancellationToken cancellationToken = default(CancellationToken)) + public async Task SaveAllActionLogsAsync(List actionLogs, CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false) { if (actionLogs != null && actionLogs.Count() > 0) { @@ -223,7 +223,8 @@ public async Task GetPreviousActionLogAsync(string userId, int action { DepartmentId = saved.DepartmentId, Status = saved, - PreviousStatus = previousStatus + PreviousStatus = previousStatus, + AutoGenerated = autoGenerated }); } @@ -344,7 +345,9 @@ public async Task SetActionForEntireDepartmentAsync(int departmentId, int logs.Add(al); } - return await SaveAllActionLogsAsync(logs); + // Bulk status operations (scheduled resets, manual department-wide resets) never generate + // per-user notifications, otherwise every member change fans out to every subscriber. + return await SaveAllActionLogsAsync(logs, autoGenerated: true); } public async Task SetActionForDepartmentGroupAsync(int departmentGroupId, int actionType, string note) @@ -366,7 +369,8 @@ public async Task SetActionForDepartmentGroupAsync(int departmentGroupId, logs.Add(al); } - return await SaveAllActionLogsAsync(logs); + // Bulk status operations never generate per-user notifications, same as the department-wide reset. + return await SaveAllActionLogsAsync(logs, autoGenerated: true); } return false; diff --git a/Core/Resgrid.Services/CallDispatchStatusService.cs b/Core/Resgrid.Services/CallDispatchStatusService.cs index 22fde959c..9d6c40f3a 100644 --- a/Core/Resgrid.Services/CallDispatchStatusService.cs +++ b/Core/Resgrid.Services/CallDispatchStatusService.cs @@ -118,7 +118,7 @@ private async Task ApplyUnitStatusesAsync(Call call, Department department, IRea DestinationType = (int)DestinationEntityTypes.Call }; - await _unitsService.SetUnitStateAsync(state, call.DepartmentId, cancellationToken); + await _unitsService.SetUnitStateAsync(state, call.DepartmentId, cancellationToken, autoGenerated: true); } } diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs index 6affe621b..9a1017858 100644 --- a/Core/Resgrid.Services/UnitsService.cs +++ b/Core/Resgrid.Services/UnitsService.cs @@ -324,7 +324,7 @@ public async Task GetUnitTypeByNameAsync(int departmentId, string type return saved; } - public async Task SetUnitStateAsync(UnitState state, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) + public async Task SetUnitStateAsync(UnitState state, int departmentId, CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false) { var previousState = await GetLastUnitStateByUnitIdAsync(state.UnitId); @@ -351,7 +351,7 @@ public async Task GetUnitTypeByNameAsync(int departmentId, string type var saved = await _unitStatesRepository.SaveOrUpdateAsync(state, cancellationToken); - _eventAggregator.SendMessage(new UnitStatusEvent { DepartmentId = departmentId, Status = saved, PreviousStatus = previousState }); + _eventAggregator.SendMessage(new UnitStatusEvent { DepartmentId = departmentId, Status = saved, PreviousStatus = previousState, AutoGenerated = autoGenerated }); return state; } diff --git a/Core/Resgrid.Services/UserStateService.cs b/Core/Resgrid.Services/UserStateService.cs index 9b197940c..af57a0172 100644 --- a/Core/Resgrid.Services/UserStateService.cs +++ b/Core/Resgrid.Services/UserStateService.cs @@ -84,7 +84,7 @@ public async Task GetPreviousUserStateAsync(string userId, int userSt return saved; } - public async Task CreateUserState(string userId, int departmentId, int userStateType, string note, CancellationToken cancellationToken = default(CancellationToken)) + public async Task CreateUserState(string userId, int departmentId, int userStateType, string note, CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false) { var us = new UserState(); us.UserId = userId; @@ -96,7 +96,7 @@ public async Task GetPreviousUserStateAsync(string userId, int userSt var saved = await _userStateRepository.SaveOrUpdateAsync(us, cancellationToken); var previousStaffing = await _userStateRepository.GetPreviousUserStateByUserIdAsync(userId, saved.UserStateId); - _eventAggregator.SendMessage(new UserStaffingEvent() { DepartmentId = departmentId, Staffing = saved, PreviousStaffing = previousStaffing }); + _eventAggregator.SendMessage(new UserStaffingEvent() { DepartmentId = departmentId, Staffing = saved, PreviousStaffing = previousStaffing, AutoGenerated = autoGenerated }); InvalidateLatestStatesForDepartmentCache(departmentId); return saved; diff --git a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs index 8f241bf05..bf2af5dd2 100644 --- a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs @@ -79,7 +79,10 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro nqi.PreviousStateId = previousState; nqi.Value = message.Status.State.ToString(); - await _outboundQueueProvider.EnqueueNotification(nqi); + // Automated status changes (i.e. call dispatch auto-status) shouldn't generate user notifications, + // only user-initiated changes do. SignalR still fires so connected clients stay current. + if (!message.AutoGenerated) + await _outboundQueueProvider.EnqueueNotification(nqi); try { @@ -109,7 +112,9 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro nqi.Value = message.Status.State.ToString(); nqi.UnitId = message.Status.UnitId; - await _outboundQueueProvider.EnqueueNotification(nqi); + // Automated status changes don't generate availability alerts, only user-initiated changes do. + if (!message.AutoGenerated) + await _outboundQueueProvider.EnqueueNotification(nqi); }; public Action unitTypeDepartmentAvailabilityHandler = async delegate (UnitStatusEvent message) @@ -130,7 +135,9 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro nqi.Value = message.Status.State.ToString(); nqi.UnitId = message.Status.UnitId; - await _outboundQueueProvider.EnqueueNotification(nqi); + // Automated status changes don't generate availability alerts, only user-initiated changes do. + if (!message.AutoGenerated) + await _outboundQueueProvider.EnqueueNotification(nqi); }; public Action userStaffingHandler = async delegate (UserStaffingEvent message) @@ -151,7 +158,10 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro nqi.Value = message.Staffing.State.ToString(); nqi.UserId = message.Staffing.UserId; - await _outboundQueueProvider.EnqueueNotification(nqi); + // Automated staffing changes (i.e. scheduled department staffing reset) shouldn't generate + // user notifications, only user-initiated changes do. + if (!message.AutoGenerated) + await _outboundQueueProvider.EnqueueNotification(nqi); }; public Action userRoleGroupAvailabilityHandler = async delegate (UserStaffingEvent message) @@ -173,7 +183,9 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro nqi.Value = message.Staffing.UserStateId.ToString(); nqi.UserId = message.Staffing.UserId; - await _outboundQueueProvider.EnqueueNotification(nqi); + // Automated staffing changes don't generate availability alerts, only user-initiated changes do. + if (!message.AutoGenerated) + await _outboundQueueProvider.EnqueueNotification(nqi); }; public Action userRoleDepartmentAvailabilityHandler = async delegate (UserStaffingEvent message) @@ -195,7 +207,11 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro nqi.Value = message.Staffing.UserStateId.ToString(); nqi.UserId = message.Staffing.UserId; - await _outboundQueueProvider.EnqueueNotification(nqi); + // Automated staffing changes don't generate availability alerts, only user-initiated changes do. + // SignalR still fires so connected clients stay current. + if (!message.AutoGenerated) + await _outboundQueueProvider.EnqueueNotification(nqi); + await _signalrProvider.PersonnelStaffingUpdated(message.Staffing.DepartmentId, message.Staffing); }; @@ -216,7 +232,11 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro nqi.PreviousStateId = previousStatus; nqi.Value = message.Status.ActionTypeId.ToString(); - await _outboundQueueProvider.EnqueueNotification(nqi); + // Automated status changes (i.e. scheduled department status reset) shouldn't generate + // user notifications, only user-initiated changes do. SignalR still fires so connected clients stay current. + if (!message.AutoGenerated) + await _outboundQueueProvider.EnqueueNotification(nqi); + await _signalrProvider.PersonnelStatusUpdated(message.Status.DepartmentId, message.Status); }; diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByTypePagedQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByTypePagedQuery.cs new file mode 100644 index 000000000..32386880e --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByTypePagedQuery.cs @@ -0,0 +1,23 @@ +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository.Queries.SystemAudits +{ + public class SelectSystemAuditsByTypePagedQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectSystemAuditsByTypePagedQuery(SqlConfiguration sqlConfiguration) => _sqlConfiguration = sqlConfiguration; + + public string GetQuery() + { + if (DataConfig.DatabaseType == DatabaseTypes.Postgres) + return $"SELECT * FROM {_sqlConfiguration.SchemaName}.systemaudits WHERE type = {_sqlConfiguration.ParameterNotation}Type AND loggedon >= {_sqlConfiguration.ParameterNotation}StartDate AND loggedon < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY loggedon DESC LIMIT {_sqlConfiguration.ParameterNotation}PageSize OFFSET {_sqlConfiguration.ParameterNotation}Offset"; + + return $"SELECT * FROM {_sqlConfiguration.SchemaName}.[SystemAudits] WHERE [Type] = {_sqlConfiguration.ParameterNotation}Type AND [LoggedOn] >= {_sqlConfiguration.ParameterNotation}StartDate AND [LoggedOn] < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY [LoggedOn] DESC OFFSET {_sqlConfiguration.ParameterNotation}Offset ROWS FETCH NEXT {_sqlConfiguration.ParameterNotation}PageSize ROWS ONLY"; + } + + public string GetQuery() where TEntity : class, IEntity => GetQuery(); + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs index 9c2fd2725..63719cc51 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs @@ -122,5 +122,52 @@ public async Task> GetByDepartmentIdPagedAsync(int depa throw; } } + + public async Task> GetByTypePagedAsync(int type, DateTime startDate, DateTime endDate, int page, int pageSize) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("Type", type); + dynamicParameters.Add("StartDate", startDate); + dynamicParameters.Add("EndDate", endDate); + var safePage = page < 1 ? 1 : page; + var safePageSize = pageSize < 1 ? 1 : pageSize; + dynamicParameters.Add("Offset", (safePage - 1) * safePageSize); + dynamicParameters.Add("PageSize", safePageSize); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex, extraMessage: $"GetByTypePagedAsync Type: {type}"); + + throw; + } + } } } diff --git a/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs b/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs index 6fd8548eb..567cfa990 100644 --- a/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs @@ -38,8 +38,8 @@ public void SetUp() .Setup(x => x.SetUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ActionLog()); _unitsService - .Setup(x => x.SetUnitStateAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((UnitState state, int _, CancellationToken __) => state); + .Setup(x => x.SetUnitStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((UnitState state, int _, CancellationToken __, bool ___) => state); _service = new CallDispatchStatusService( _departmentSettingsService.Object, @@ -85,7 +85,8 @@ public async Task ApplyDispatchStatusesAsync_uses_default_shift_and_unit_dispatc s.DestinationId == 12 && s.DestinationType == (int)DestinationEntityTypes.Call), 7, - It.IsAny()), Times.Once); + It.IsAny(), + true), Times.Once); } [Test] @@ -116,7 +117,8 @@ public async Task ApplyReleaseStatusesAsync_uses_configured_release_statuses() s.DestinationId == 22 && s.DestinationType == (int)DestinationEntityTypes.Call), 7, - It.IsAny()), Times.Once); + It.IsAny(), + true), Times.Once); } [Test] @@ -144,7 +146,8 @@ public async Task ApplyDispatchStatusesAsync_skips_shift_personnel_when_auto_sta s.DestinationId == 32 && s.DestinationType == (int)DestinationEntityTypes.Call), 7, - It.IsAny()), Times.Once); + It.IsAny(), + true), Times.Once); } [Test] @@ -181,11 +184,13 @@ public async Task ApplyDispatchStatusesAsync_uses_unit_type_override_only_for_ma _unitsService.Verify(x => x.SetUnitStateAsync( It.Is(s => s.UnitId == 11 && s.State == 44 && s.DestinationId == 42), 7, - It.IsAny()), Times.Once); + It.IsAny(), + true), Times.Once); _unitsService.Verify(x => x.SetUnitStateAsync( It.Is(s => s.UnitId == 12 && s.State == (int)UnitStateTypes.Responding && s.DestinationId == 42), 7, - It.IsAny()), Times.Once); + It.IsAny(), + true), Times.Once); } [Test] @@ -220,7 +225,8 @@ public async Task ApplyReleaseStatusesAsync_uses_unit_type_release_override_when _unitsService.Verify(x => x.SetUnitStateAsync( It.Is(s => s.UnitId == 11 && s.State == 77 && s.DestinationId == 52), 7, - It.IsAny()), Times.Once); + It.IsAny(), + true), Times.Once); } } } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index fa5c64430..e5b478409 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -4551,52 +4551,6 @@ Is the user a group admin - - - UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a - function that is setting status for the current user. - - - - - The state/staffing level of the user to set for the user. - - - - - Note for the staffing level - - - - - The result object for a state/staffing level request. - - - - - The UserId GUID/UUID for the user state/staffing level being return - - - - - The full name of the user for the state/staffing level being returned - - - - - The current staffing level (state) type for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Staffing note for the User's staffing - - Input data to add a staffing schedule in the Resgrid system @@ -4702,6 +4656,52 @@ Note for this staffing schedule + + + UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a + function that is setting status for the current user. + + + + + The state/staffing level of the user to set for the user. + + + + + Note for the staffing level + + + + + The result object for a state/staffing level request. + + + + + The UserId GUID/UUID for the user state/staffing level being return + + + + + The full name of the user for the state/staffing level being returned + + + + + The current staffing level (state) type for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Staffing note for the User's staffing + + A resrouce in the system this could be a user or unit @@ -10206,379 +10206,209 @@ Identifier of the new npte - + - The result of getting all personnel filters for the system + A GPS location for a point in time of a specificed person - + - The Id value of the filter + PersonId of the person that the location is for - + - The type of the filter + The timestamp of the location in UTC - + - The filters name + GPS Latitude of the Person - + - Result containing all the data required to populate the New Call form + GPS Longitude of the Person - + - Response Data + GPS Latitude\Longitude Accuracy of the Person - + - Result that contains all the options available to filter personnel against compatible Resgrid APIs + GPS Altitude of the Person - + - Response Data + GPS Altitude Accuracy of the Person - + - Result containing all the data required to populate the New Call form + GPS Speed of the Person - + - Response Data + GPS Heading of the Person - + - Information about a User + A unit location in the Resgrid system - + - The UserId GUID/UUID for the user + Response Data - + - DepartmentId of the deparment the user belongs to + The information about a specific unit's location - + - Department specificed ID number for this user + Id of the Person - + - The Users First Name + The Timestamp for the location in UTC - + - The Users Last Name + GPS Latitude of the Person - + - The Users Email Address + GPS Longitude of the Person - + - The Users Mobile Telephone Number + GPS Latitude\Longitude Accuracy of the Person - + - GroupId the user is assigned to (0 for no group) + GPS Altitude of the Person - + - Name of the group the user is assigned to + GPS Altitude Accuracy of the Person - + - Enumeration/List of roles the user currently holds + GPS Speed of the Person - + - The current action/status type for the user + GPS Heading of the Person - + - The current action/status string for the user + The result of getting the current staffing for a user - + - The current action/status color hex string for the user + Response Data - + - The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + Information about a User staffing - + - The current action/status destination id for the user + The UserId GUID/UUID for the user status being return - + - The current action/status destination name for the user + DepartmentId of the deparment the user belongs to - + - The current staffing level (state) type for the user + The current staffing type for the user - + - The current staffing level (state) string for the user + The timestamp of the last staffing. This is converted UTC version of the timestamp. - + - The current staffing level (state) color hex string for the user + The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. - + - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + Note for this staffing - + - Users last known location + Saves (sets) and Personnel Staffing in the system, for a single user - + - Sorting weight for the user + UnitId of the apparatus that the state is being set for - + - User Defined Field values for this personnel record + The UnitStateType of the Unit - + - A GPS location for a point in time of a specificed person + The timestamp of the status event in UTC - + - PersonId of the person that the location is for + The timestamp of the status event in the local time of the device - + - The timestamp of the location in UTC + User provided note for this event - + - GPS Latitude of the Person + The event id used for queuing on mobile applications - + - GPS Longitude of the Person + Depicts a result after saving a person status - + - GPS Latitude\Longitude Accuracy of the Person + Response Data - + - GPS Altitude of the Person - - - - - GPS Altitude Accuracy of the Person - - - - - GPS Speed of the Person - - - - - GPS Heading of the Person - - - - - A unit location in the Resgrid system - - - - - Response Data - - - - - The information about a specific unit's location - - - - - Id of the Person - - - - - The Timestamp for the location in UTC - - - - - GPS Latitude of the Person - - - - - GPS Longitude of the Person - - - - - GPS Latitude\Longitude Accuracy of the Person - - - - - GPS Altitude of the Person - - - - - GPS Altitude Accuracy of the Person - - - - - GPS Speed of the Person - - - - - GPS Heading of the Person - - - - - The result of getting the current staffing for a user - - - - - Response Data - - - - - Information about a User staffing - - - - - The UserId GUID/UUID for the user status being return - - - - - DepartmentId of the deparment the user belongs to - - - - - The current staffing type for the user - - - - - The timestamp of the last staffing. This is converted UTC version of the timestamp. - - - - - The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. - - - - - Note for this staffing - - - - - Saves (sets) and Personnel Staffing in the system, for a single user - - - - - UnitId of the apparatus that the state is being set for - - - - - The UnitStateType of the Unit - - - - - The timestamp of the status event in UTC - - - - - The timestamp of the status event in the local time of the device - - - - - User provided note for this event - - - - - The event id used for queuing on mobile applications - - - - - Depicts a result after saving a person status - - - - - Response Data - - - - - Saves (sets) and Personnel Status in the system, for a single user + Saves (sets) and Personnel Status in the system, for a single user @@ -10879,113 +10709,283 @@ Response Data - + - Result containing all the data required to populate the New Call form + The result of getting all personnel filters for the system - + - Response Data + The Id value of the filter - + - Details of a protocol + The type of the filter - + - Protocol id + The filters name - + - Department id + Result containing all the data required to populate the New Call form - + - Name of the Protocol + Response Data - + - Protocol code + Result that contains all the options available to filter personnel against compatible Resgrid APIs - + - This this protocol disabled + Response Data - + - Protocol description + Result containing all the data required to populate the New Call form - + - Text of the protocol + Response Data - + - UTC date and time when the Protocol was created + Information about a User - + - UserId of the user who created the protocol + The UserId GUID/UUID for the user - + - UTC timestamp of when the Protocol was updated + DepartmentId of the deparment the user belongs to - + - Minimum triggering Weight of the Protocol + Department specificed ID number for this user - + - UserId that last updated the Protocol + The Users First Name - + - Triggers used to activate this Protocol + The Users Last Name - + - Attachments for this Protocol + The Users Email Address - + - Questions used to determine if this Protocol needs to be used or not + The Users Mobile Telephone Number - + - State type + GroupId the user is assigned to (0 for no group) - + - Result containing all the data required to populate the New Call form + Name of the group the user is assigned to - + - Response Data + Enumeration/List of roles the user currently holds - - Composite dashboard report (scalar totals, dense series, breakdowns). + + + The current action/status type for the user + + + + + The current action/status string for the user + + + + + The current action/status color hex string for the user + + + + + The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + + + + + The current action/status destination id for the user + + + + + The current action/status destination name for the user + + + + + The current staffing level (state) type for the user + + + + + The current staffing level (state) string for the user + + + + + The current staffing level (state) color hex string for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Users last known location + + + + + Sorting weight for the user + + + + + User Defined Field values for this personnel record + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + + Details of a protocol + + + + + Protocol id + + + + + Department id + + + + + Name of the Protocol + + + + + Protocol code + + + + + This this protocol disabled + + + + + Protocol description + + + + + Text of the protocol + + + + + UTC date and time when the Protocol was created + + + + + UserId of the user who created the protocol + + + + + UTC timestamp of when the Protocol was updated + + + + + Minimum triggering Weight of the Protocol + + + + + UserId that last updated the Protocol + + + + + Triggers used to activate this Protocol + + + + + Attachments for this Protocol + + + + + Questions used to determine if this Protocol needs to be used or not + + + + + State type + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + Composite dashboard report (scalar totals, dense series, breakdowns). Response Data @@ -12262,545 +12262,545 @@ Default constructor - + - Result that contains all the options available to filter units against compatible Resgrid APIs + Depicts a result after saving a unit status - + Response Data - + - A unit in the Resgrid system + Object inputs for setting a users Status/Action. If this object is used in an operation that sets + a status for the current user the UserId value in this object will be ignored. - + - Response Data + UnitId of the apparatus that the state is being set for - + - The information about a specific unit + The UnitStateType of the Unit - + - Id of the Unit + The Call/Station the unit is responding to - + - The Id of the department the unit is under + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). - + - Name of the Unit + The timestamp of the status event in UTC - + - Department assigned type for the unit + The timestamp of the status event in the local time of the device - + - Department assigned type id for the unit + User provided note for this event - + - Custom Statuses Set Id + GPS Latitude of the Unit - + - Station Id of the station housing the unit (0 means no station) + GPS Longitude of the Unit - + - Name of the station the unit is under + GPS Latitude\Longitude Accuracy of the Unit - + - Vehicle Identification Number for the unit + GPS Altitude of the Unit - + - Plate Number for the Unit + GPS Altitude Accuracy of the Unit - + - Is the unit 4-Wheel drive + GPS Speed of the Unit - + - Does the unit require a special permit to drive + GPS Heading of the Unit - + - Id number of the units current destionation (0 means no destination) + The event id used for queuing on mobile applications - + - The current status/state of the Unit + The accountability roles filed for this event - + - The Timestamp of the status + Role filled by a User on a Unit for an event - + - The units current Latitude + Id of the locally stored event - + - The units current Longitude + Local Event Id - + - Current user provide status note + UserId of the user filling the role - + - User Defined Field values for this unit + RoleId of the role being filled - + - Unit role information for roles on a unit + The name of the Role - + - Unit Role Id + Depicts a unit status in the Resgrid system. - + - User Id of the user in the role (could be null) + Response Data - + - Name of the Role + Depicts a unit's status - + - Name of the user in the role (could be null) + Unit Id - + - Multiple Unit infos Result + Units Name - + - Response Data + The Type of the Unit - + - Default constructor + Units current Status (State) - + - The information about a specific unit + CSS for status (for display) - + - Id of the Unit + CSS Style for status (for display) - + - The Id of the department the unit is under + Timestamp of this Unit State - + - Name of the Unit + Timestamp in Utc of this Unit State - + - Department assigned type for the unit + Destination Id (Station or Call) - + - Department assigned type id for the unit + Destination type (Station, Call, or POI). - + - Custom Statuses Set Id + Name of the Desination (Call or Station) - + - Station Id of the station housing the unit (0 means no station) + Destination address. - + - Name of the station the unit is under + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. - + - Vehicle Identification Number for the unit + Note for the State - + - Plate Number for the Unit + Latitude - + - Is the unit 4-Wheel drive + Longitude - + - Does the unit require a special permit to drive + Name of the Group the Unit is in - + - Id number of the units current destination (0 means no destination) + Id of the Group the Unit is in - + - Name of the units current destination (0 means no destination) + Unit statuses (states) - + - The current status/state of the Unit + Response Data - + - The current status/state of the Unit as a name + Default constructor - + - The current status/state of the Unit color + Result that contains all the options available to filter units against compatible Resgrid APIs - + - The Timestamp of the status + Response Data - + - The Timestamp of the status in UTC/GMT + A unit in the Resgrid system - + - The units current Latitude + Response Data - + - The units current Longitude + The information about a specific unit - + - Current user provide status note + Id of the Unit - + - Units Roles + The Id of the department the unit is under - + - Multiple Units Result + Name of the Unit - + - Response Data + Department assigned type for the unit - + - Default constructor + Department assigned type id for the unit - + - Depicts a result after saving a unit status + Custom Statuses Set Id - + - Response Data + Station Id of the station housing the unit (0 means no station) - + - Object inputs for setting a users Status/Action. If this object is used in an operation that sets - a status for the current user the UserId value in this object will be ignored. + Name of the station the unit is under - + - UnitId of the apparatus that the state is being set for + Vehicle Identification Number for the unit - + - The UnitStateType of the Unit + Plate Number for the Unit - + - The Call/Station the unit is responding to + Is the unit 4-Wheel drive - + - Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + Does the unit require a special permit to drive - + - The timestamp of the status event in UTC + Id number of the units current destionation (0 means no destination) - + - The timestamp of the status event in the local time of the device + The current status/state of the Unit - + - User provided note for this event + The Timestamp of the status - + - GPS Latitude of the Unit + The units current Latitude - + - GPS Longitude of the Unit + The units current Longitude - + - GPS Latitude\Longitude Accuracy of the Unit + Current user provide status note - + - GPS Altitude of the Unit + User Defined Field values for this unit - + - GPS Altitude Accuracy of the Unit + Unit role information for roles on a unit - + - GPS Speed of the Unit + Unit Role Id - + - GPS Heading of the Unit + User Id of the user in the role (could be null) - + - The event id used for queuing on mobile applications + Name of the Role - + - The accountability roles filed for this event + Name of the user in the role (could be null) - + - Role filled by a User on a Unit for an event + Multiple Unit infos Result - + - Id of the locally stored event + Response Data - + - Local Event Id + Default constructor - + - UserId of the user filling the role + The information about a specific unit - + - RoleId of the role being filled + Id of the Unit - + - The name of the Role + The Id of the department the unit is under - + - Depicts a unit status in the Resgrid system. + Name of the Unit - + - Response Data + Department assigned type for the unit - + - Depicts a unit's status + Department assigned type id for the unit - + - Unit Id + Custom Statuses Set Id - + - Units Name + Station Id of the station housing the unit (0 means no station) - + - The Type of the Unit + Name of the station the unit is under - + - Units current Status (State) + Vehicle Identification Number for the unit - + - CSS for status (for display) + Plate Number for the Unit - + - CSS Style for status (for display) + Is the unit 4-Wheel drive - + - Timestamp of this Unit State + Does the unit require a special permit to drive - + - Timestamp in Utc of this Unit State + Id number of the units current destination (0 means no destination) - + - Destination Id (Station or Call) + Name of the units current destination (0 means no destination) - + - Destination type (Station, Call, or POI). + The current status/state of the Unit - + - Name of the Desination (Call or Station) + The current status/state of the Unit as a name - + - Destination address. + The current status/state of the Unit color - + - Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not - suitable for programmatic branching; use as the - machine-readable discriminator instead. + The Timestamp of the status - + - Note for the State + The Timestamp of the status in UTC/GMT - + - Latitude + The units current Latitude - + - Longitude + The units current Longitude - + - Name of the Group the Unit is in + Current user provide status note - + - Id of the Group the Unit is in + Units Roles - + - Unit statuses (states) + Multiple Units Result - + Response Data - + Default constructor diff --git a/Workers/Resgrid.Workers.Framework/Logic/StaffingScheduleLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/StaffingScheduleLogic.cs index 6a2cad58c..700aa66c4 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/StaffingScheduleLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/StaffingScheduleLogic.cs @@ -33,7 +33,7 @@ public async Task> Process(StaffingScheduleQueueItem item) { if (item.ScheduledTask.TaskType == (int)TaskTypes.UserStaffingLevel) { - await _userStateService.CreateUserState(item.ScheduledTask.UserId, item.ScheduledTask.DepartmentId, int.Parse(item.ScheduledTask.Data), item.ScheduledTask.Note); + await _userStateService.CreateUserState(item.ScheduledTask.UserId, item.ScheduledTask.DepartmentId, int.Parse(item.ScheduledTask.Data), item.ScheduledTask.Note, autoGenerated: true); } else if (item.ScheduledTask.TaskType == (int)TaskTypes.DepartmentStaffingReset) { @@ -41,7 +41,7 @@ public async Task> Process(StaffingScheduleQueueItem item) foreach (var user in users) { - await _userStateService.CreateUserState(user.UserId, item.ScheduledTask.DepartmentId, int.Parse(item.ScheduledTask.Data), $"Department Staffing Reset {item.ScheduledTask.ScheduledTaskId}"); + await _userStateService.CreateUserState(user.UserId, item.ScheduledTask.DepartmentId, int.Parse(item.ScheduledTask.Data), $"Department Staffing Reset {item.ScheduledTask.ScheduledTaskId}", autoGenerated: true); } } } From 3654b20488dd51b68ce205e46edfe95cab50100d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 10 Aug 2026 17:53:43 -0700 Subject: [PATCH 4/6] RG-T117 PR#457 fixes --- .../Areas/User/Security/Security.ar.resx | 6 +++ .../Areas/User/Security/Security.de.resx | 6 +++ .../Areas/User/Security/Security.en.resx | 6 +++ .../Areas/User/Security/Security.es.resx | 6 +++ .../Areas/User/Security/Security.fr.resx | 6 +++ .../Areas/User/Security/Security.it.resx | 6 +++ .../Areas/User/Security/Security.pl.resx | 6 +++ .../Areas/User/Security/Security.sv.resx | 6 +++ .../Areas/User/Security/Security.uk.resx | 6 +++ .../Repositories/IChatRepositories.cs | 6 +++ .../Services/IActionLogsService.cs | 1 + Core/Resgrid.Services/ChatChannelService.cs | 53 ++++++++++++++++--- .../PermissionGateServiceBase.cs | 18 ++++--- .../ChatRepositories.cs | 32 +++++++++++ ...ectSystemAuditsByDepartmentIdPagedQuery.cs | 4 +- .../SelectSystemAuditsByTypePagedQuery.cs | 4 +- .../SelectSystemAuditsByUserIdPagedQuery.cs | 4 +- .../SystemAuditRepository.cs | 28 ++++++---- .../Services/ChatIncidentBackfillTests.cs | 45 +++++++++++++++- .../Services/DispatchAccessServiceTests.cs | 27 ++++++++++ .../RequiresIncidentCapabilityFilterTests.cs | 29 ++++++++-- .../v4/IncidentCommandController.cs | 9 ++++ .../RequiresIncidentCapabilityAttribute.cs | 11 +++- .../Areas/User/Views/Security/Index.cshtml | 12 ++--- 24 files changed, 297 insertions(+), 40 deletions(-) diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx index 0f3304ec2..91a8aaa9f 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx @@ -325,4 +325,10 @@ يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل. يجب أن تتكون كلمة المرور من {0} أحرف على الأقل. لا يمكن أن يكون الحد الأدنى لطول كلمة المرور أقل من الإعداد الافتراضي للنظام وهو 8 أحرف. + Use Calendar Sync + Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. + Dispatch App Login + Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. + Command App Login + Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx index 83f24b462..bf680f32d 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx @@ -927,4 +927,10 @@ Das Passwort muss mindestens einen Kleinbuchstaben enthalten. Das Passwort muss mindestens {0} Zeichen lang sein. Die Mindestlänge des Passworts darf nicht kleiner sein als der Systemstandard von 8 Zeichen. + Use Calendar Sync + Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. + Dispatch App Login + Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. + Command App Login + Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx index 568a926aa..c479a1ec1 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx @@ -372,6 +372,12 @@ Minimum password length cannot be less than the system default of 8 characters. Delete Log Entries Who in your department is allowed to delete log entries + Use Calendar Sync + Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. + Dispatch App Login + Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. + Command App Login + Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx index dc2437fd4..b9c0f0848 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx @@ -331,6 +331,12 @@ La contraseña debe contener al menos una letra minúscula. La contraseña debe tener al menos {0} caracteres. La longitud mínima de la contraseña no puede ser menor que el valor predeterminado del sistema de 8 caracteres. + Use Calendar Sync + Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. + Dispatch App Login + Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. + Command App Login + Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx index 552256860..63c0a2db6 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx @@ -927,4 +927,10 @@ Le mot de passe doit contenir au moins une lettre minuscule. Le mot de passe doit comporter au moins {0} caractères. La longueur minimale du mot de passe ne peut pas être inférieure à la valeur par défaut du système de 8 caractères. + Use Calendar Sync + Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. + Dispatch App Login + Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. + Command App Login + Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx index 2f752ecd3..51fb0b16e 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx @@ -927,4 +927,10 @@ La password deve contenere almeno una lettera minuscola. La password deve essere lunga almeno {0} caratteri. La lunghezza minima della password non può essere inferiore al valore predefinito di sistema di 8 caratteri. + Use Calendar Sync + Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. + Dispatch App Login + Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. + Command App Login + Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx index cfa7b6573..3c04894a2 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx @@ -927,4 +927,10 @@ Hasło musi zawierać co najmniej jedną małą literę. Hasło musi mieć co najmniej {0} znaków. Minimalna długość hasła nie może być mniejsza niż systemowe minimum wynoszące 8 znaków. + Use Calendar Sync + Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. + Dispatch App Login + Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. + Command App Login + Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx index 40a31a97f..b94890373 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx @@ -927,4 +927,10 @@ Lösenordet måste innehålla minst en liten bokstav. Lösenordet måste vara minst {0} tecken långt. Minsta lösenordslängd kan inte vara kortare än systemets standardvärde på 8 tecken. + Use Calendar Sync + Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. + Dispatch App Login + Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. + Command App Login + Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx index 7e452c3cf..8c9225f1e 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx @@ -927,4 +927,10 @@ Пароль повинен містити щонайменше одну малу літеру. Пароль повинен містити щонайменше {0} символів. Мінімальна довжина пароля не може бути меншою за системний мінімум — 8 символів. + Use Calendar Sync + Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. + Dispatch App Login + Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. + Command App Login + Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. diff --git a/Core/Resgrid.Model/Repositories/IChatRepositories.cs b/Core/Resgrid.Model/Repositories/IChatRepositories.cs index a47d3614a..200445f7e 100644 --- a/Core/Resgrid.Model/Repositories/IChatRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IChatRepositories.cs @@ -56,6 +56,12 @@ public interface IChatChannelRepository : IRepository /// Targeted lock flag update (see ). Task SetLockedAsync(string chatChannelId, bool locked, string lockedByUserId, DateTime? lockedOn, DateTime modifiedOn, CancellationToken cancellationToken); + /// + /// Rebinds a reused command-scoped channel (command/leads/dispatch) to a new incident command and + /// clears its archived state in one targeted update (see ). + /// + Task RebindToIncidentCommandAsync(string chatChannelId, string incidentCommandId, DateTime modifiedOn, CancellationToken cancellationToken); + /// /// Atomically creates a DM channel plus its member rows in one transaction. The channel insert /// uses insert-if-absent on (DepartmentId, DmKey) so a losing racer simply reads the winner; diff --git a/Core/Resgrid.Model/Services/IActionLogsService.cs b/Core/Resgrid.Model/Services/IActionLogsService.cs index 99b7ab3d8..bafd36681 100644 --- a/Core/Resgrid.Model/Services/IActionLogsService.cs +++ b/Core/Resgrid.Model/Services/IActionLogsService.cs @@ -84,6 +84,7 @@ public interface IActionLogsService /// /// The action logs. /// The cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// True when the status changes are from an automated or bulk process (i.e. scheduled or department-wide reset) and should not generate user notifications. /// Task<System.Boolean>. Task SaveAllActionLogsAsync(List actionLogs, CancellationToken cancellationToken = default(CancellationToken), bool autoGenerated = false); diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs index 362a5feb7..6abda3ce1 100644 --- a/Core/Resgrid.Services/ChatChannelService.cs +++ b/Core/Resgrid.Services/ChatChannelService.cs @@ -692,7 +692,7 @@ public async Task GetUserMembershipAsync(string chatChannelId var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(command.CallId, (int)ChatChannelType.IncidentCommand); if (existing != null) - return existing; + return await RebindCommandScopedChannelAsync(existing, command.IncidentCommandId, cancellationToken); return await InsertProvisionedChannelAsync(new ChatChannel { @@ -713,7 +713,7 @@ public async Task GetUserMembershipAsync(string chatChannelId var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(command.CallId, (int)ChatChannelType.IncidentLeads); if (existing != null) - return existing; + return await RebindCommandScopedChannelAsync(existing, command.IncidentCommandId, cancellationToken); return await InsertProvisionedChannelAsync(new ChatChannel { @@ -734,7 +734,7 @@ public async Task GetUserMembershipAsync(string chatChannelId var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)ChatChannelType.IncidentDispatch); if (existing != null) - return existing; + return await RebindCommandScopedChannelAsync(existing, incidentCommandId, cancellationToken); return await InsertProvisionedChannelAsync(new ChatChannel { @@ -748,6 +748,36 @@ public async Task GetUserMembershipAsync(string chatChannelId }, () => _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)ChatChannelType.IncidentDispatch), cancellationToken); } + /// + /// A call can host sequential incident commands (close command, establish a new one later), but its + /// command/leads/dispatch channels are singletons per call and get reused. A reused channel still + /// carries the closed command's id and archived state, so without this the new command's channels + /// stay frozen and its close/reopen archive sweeps match nothing. Targeted update — a full-row + /// write would rewind the atomic LastMessageSeq allocator. + /// + private async Task RebindCommandScopedChannelAsync(ChatChannel channel, string incidentCommandId, CancellationToken cancellationToken) + { + if (channel == null || string.IsNullOrWhiteSpace(incidentCommandId)) + return channel; + + var commandChanged = !string.Equals(channel.IncidentCommandId, incidentCommandId, StringComparison.OrdinalIgnoreCase); + if (!commandChanged && !channel.IsArchived) + return channel; + + await _chatChannelRepository.RebindToIncidentCommandAsync(channel.ChatChannelId, incidentCommandId, DateTime.UtcNow, cancellationToken); + + channel.IncidentCommandId = incidentCommandId; + channel.IsArchived = false; + channel.ArchivedOn = null; + channel.ModifiedOn = DateTime.UtcNow; + + // The archive flag gates posting per cached permission verdicts; clients also need to re-read it. + await _chatPermissionService.InvalidateChannelCacheAsync(channel.ChatChannelId); + PublishChannelEvent(channel, ChatEventKinds.ChannelUpdated); + + return channel; + } + public async Task EnsureIncidentChannelsAsync(IncidentCommand command, IEnumerable nodes, CancellationToken cancellationToken = default(CancellationToken)) { if (command == null || command.CallId <= 0) @@ -779,14 +809,25 @@ public async Task GetUserMembershipAsync(string chatChannelId if (!existing.Any(c => c.ChannelType == (int)ChatChannelType.Incident)) await EnsureIncidentChannelAsync(command.DepartmentId, command.CallId, null, cancellationToken); - if (!existing.Any(c => c.ChannelType == (int)ChatChannelType.IncidentCommand)) + // Command-scoped channels are reused across sequential commands on the same call, so a + // found channel still needs rebinding to this command (and unarchiving) — see the helper. + var commandChannel = existing.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentCommand); + if (commandChannel == null) await EnsureCommandChannelAsync(command, cancellationToken); + else + await RebindCommandScopedChannelAsync(commandChannel, command.IncidentCommandId, cancellationToken); - if (!existing.Any(c => c.ChannelType == (int)ChatChannelType.IncidentLeads)) + var leadsChannel = existing.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentLeads); + if (leadsChannel == null) await EnsureLeadsChannelAsync(command, cancellationToken); + else + await RebindCommandScopedChannelAsync(leadsChannel, command.IncidentCommandId, cancellationToken); - if (!existing.Any(c => c.ChannelType == (int)ChatChannelType.IncidentDispatch)) + var dispatchChannel = existing.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentDispatch); + if (dispatchChannel == null) await EnsureDispatchChannelAsync(command.DepartmentId, command.CallId, command.IncidentCommandId, cancellationToken); + else + await RebindCommandScopedChannelAsync(dispatchChannel, command.IncidentCommandId, cancellationToken); var provisionedNodeIds = new HashSet( existing.Where(c => c.ChannelType == (int)ChatChannelType.IncidentLane && !string.IsNullOrWhiteSpace(c.CommandStructureNodeId)) diff --git a/Core/Resgrid.Services/PermissionGateServiceBase.cs b/Core/Resgrid.Services/PermissionGateServiceBase.cs index 938519075..39b241109 100644 --- a/Core/Resgrid.Services/PermissionGateServiceBase.cs +++ b/Core/Resgrid.Services/PermissionGateServiceBase.cs @@ -14,8 +14,8 @@ namespace Resgrid.Services /// /// Both answer the same question against a different value, and both /// gate access to private traffic, so the rules that matter live in one place: a missing permission - /// row means everyone, the department's managing user counts as an admin, and an evaluation failure - /// denies rather than allows. + /// row means every active department member, the department's managing user counts as an admin, and + /// an evaluation failure denies rather than allows. /// public abstract class PermissionGateServiceBase { @@ -138,20 +138,24 @@ protected async Task> GetAllowedUserIdsAsync(int departmentId) /// /// Mirrors how the department rights endpoint decides every other permission: department admin, /// group admin, and the user's personnel roles evaluated against the permission row. A missing row - /// means everyone, which already handles. + /// means every active department member. /// private async Task EvaluateAsync(int departmentId, string userId) { try { + // Membership comes first: a missing permission row means "everyone in the department", + // never "everyone on the platform". These gates are asked about a channel's or incident's + // department — not necessarily the caller's own — so the open default must not admit + // non-members, or members who were disabled or removed. + var membership = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, false); + if (membership == null || membership.IsDisabled.GetValueOrDefault() || membership.IsDeleted) + return false; + var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionType); if (permission == null) return true; - var membership = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, false); - if (membership == null) - return false; - var isDepartmentAdmin = membership.IsAdmin.GetValueOrDefault(); // The department's managing user is always an admin, the same carve-out the rights endpoint makes. diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs index 5286f2781..ccec4b3cd 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs @@ -533,6 +533,38 @@ public async Task SetLockedAsync(string chatChannelId, bool locked, string } } + public async Task RebindToIncidentCommandAsync(string chatChannelId, string incidentCommandId, DateTime modifiedOn, CancellationToken cancellationToken) + { + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("Id", chatChannelId); + parameters.Add("IncidentCommandId", incidentCommandId); + parameters.Add("ModifiedOn", modifiedOn, DbType.DateTime2); + var notation = _sqlConfiguration.ParameterNotation; + var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres + ? $"UPDATE {_sqlConfiguration.SchemaName}.chatchannels SET incidentcommandid = {notation}IncidentCommandId, isarchived = FALSE, archivedon = NULL, modifiedon = {notation}ModifiedOn WHERE chatchannelid = {notation}Id" + : $"UPDATE {_sqlConfiguration.SchemaName}.[ChatChannels] SET [IncidentCommandId] = {notation}IncidentCommandId, [IsArchived] = 0, [ArchivedOn] = NULL, [ModifiedOn] = {notation}ModifiedOn WHERE [ChatChannelId] = {notation}Id"; + + var execute = new Func>(connection => + connection.ExecuteAsync(sql, parameters, _unitOfWork.Transaction)); + + if (_unitOfWork?.Connection == null) + { + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(cancellationToken); + return await execute(connection) > 0; + } + + return await execute(_unitOfWork.CreateOrGetConnection()) > 0; + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + public async Task CreateDirectMessageChannelAsync(ChatChannel channel, IEnumerable members, CancellationToken cancellationToken) { try diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByDepartmentIdPagedQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByDepartmentIdPagedQuery.cs index 239124a19..370c820c6 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByDepartmentIdPagedQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByDepartmentIdPagedQuery.cs @@ -13,9 +13,9 @@ public class SelectSystemAuditsByDepartmentIdPagedQuery : ISelectQuery public string GetQuery() { if (DataConfig.DatabaseType == DatabaseTypes.Postgres) - return $"SELECT * FROM {_sqlConfiguration.SchemaName}.systemaudits WHERE departmentid = {_sqlConfiguration.ParameterNotation}DepartmentId AND loggedon >= {_sqlConfiguration.ParameterNotation}StartDate AND loggedon < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY loggedon DESC LIMIT {_sqlConfiguration.ParameterNotation}PageSize OFFSET {_sqlConfiguration.ParameterNotation}Offset"; + return $"SELECT * FROM {_sqlConfiguration.SchemaName}.systemaudits WHERE departmentid = {_sqlConfiguration.ParameterNotation}DepartmentId AND loggedon >= {_sqlConfiguration.ParameterNotation}StartDate AND loggedon < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY loggedon DESC, systemauditid DESC LIMIT {_sqlConfiguration.ParameterNotation}PageSize OFFSET {_sqlConfiguration.ParameterNotation}Offset"; - return $"SELECT * FROM {_sqlConfiguration.SchemaName}.[SystemAudits] WHERE [DepartmentId] = {_sqlConfiguration.ParameterNotation}DepartmentId AND [LoggedOn] >= {_sqlConfiguration.ParameterNotation}StartDate AND [LoggedOn] < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY [LoggedOn] DESC OFFSET {_sqlConfiguration.ParameterNotation}Offset ROWS FETCH NEXT {_sqlConfiguration.ParameterNotation}PageSize ROWS ONLY"; + return $"SELECT * FROM {_sqlConfiguration.SchemaName}.[SystemAudits] WHERE [DepartmentId] = {_sqlConfiguration.ParameterNotation}DepartmentId AND [LoggedOn] >= {_sqlConfiguration.ParameterNotation}StartDate AND [LoggedOn] < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY [LoggedOn] DESC, [SystemAuditId] DESC OFFSET {_sqlConfiguration.ParameterNotation}Offset ROWS FETCH NEXT {_sqlConfiguration.ParameterNotation}PageSize ROWS ONLY"; } public string GetQuery() where TEntity : class, IEntity => GetQuery(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByTypePagedQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByTypePagedQuery.cs index 32386880e..3bca1f7c7 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByTypePagedQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByTypePagedQuery.cs @@ -13,9 +13,9 @@ public class SelectSystemAuditsByTypePagedQuery : ISelectQuery public string GetQuery() { if (DataConfig.DatabaseType == DatabaseTypes.Postgres) - return $"SELECT * FROM {_sqlConfiguration.SchemaName}.systemaudits WHERE type = {_sqlConfiguration.ParameterNotation}Type AND loggedon >= {_sqlConfiguration.ParameterNotation}StartDate AND loggedon < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY loggedon DESC LIMIT {_sqlConfiguration.ParameterNotation}PageSize OFFSET {_sqlConfiguration.ParameterNotation}Offset"; + return $"SELECT * FROM {_sqlConfiguration.SchemaName}.systemaudits WHERE type = {_sqlConfiguration.ParameterNotation}Type AND loggedon >= {_sqlConfiguration.ParameterNotation}StartDate AND loggedon < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY loggedon DESC, systemauditid DESC LIMIT {_sqlConfiguration.ParameterNotation}PageSize OFFSET {_sqlConfiguration.ParameterNotation}Offset"; - return $"SELECT * FROM {_sqlConfiguration.SchemaName}.[SystemAudits] WHERE [Type] = {_sqlConfiguration.ParameterNotation}Type AND [LoggedOn] >= {_sqlConfiguration.ParameterNotation}StartDate AND [LoggedOn] < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY [LoggedOn] DESC OFFSET {_sqlConfiguration.ParameterNotation}Offset ROWS FETCH NEXT {_sqlConfiguration.ParameterNotation}PageSize ROWS ONLY"; + return $"SELECT * FROM {_sqlConfiguration.SchemaName}.[SystemAudits] WHERE [Type] = {_sqlConfiguration.ParameterNotation}Type AND [LoggedOn] >= {_sqlConfiguration.ParameterNotation}StartDate AND [LoggedOn] < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY [LoggedOn] DESC, [SystemAuditId] DESC OFFSET {_sqlConfiguration.ParameterNotation}Offset ROWS FETCH NEXT {_sqlConfiguration.ParameterNotation}PageSize ROWS ONLY"; } public string GetQuery() where TEntity : class, IEntity => GetQuery(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByUserIdPagedQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByUserIdPagedQuery.cs index 79d27bd90..f3e8695e1 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByUserIdPagedQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/SystemAudits/SelectSystemAuditsByUserIdPagedQuery.cs @@ -13,9 +13,9 @@ public class SelectSystemAuditsByUserIdPagedQuery : ISelectQuery public string GetQuery() { if (DataConfig.DatabaseType == DatabaseTypes.Postgres) - return $"SELECT * FROM {_sqlConfiguration.SchemaName}.systemaudits WHERE userid = {_sqlConfiguration.ParameterNotation}UserId AND loggedon >= {_sqlConfiguration.ParameterNotation}StartDate AND loggedon < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY loggedon DESC LIMIT {_sqlConfiguration.ParameterNotation}PageSize OFFSET {_sqlConfiguration.ParameterNotation}Offset"; + return $"SELECT * FROM {_sqlConfiguration.SchemaName}.systemaudits WHERE userid = {_sqlConfiguration.ParameterNotation}UserId AND loggedon >= {_sqlConfiguration.ParameterNotation}StartDate AND loggedon < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY loggedon DESC, systemauditid DESC LIMIT {_sqlConfiguration.ParameterNotation}PageSize OFFSET {_sqlConfiguration.ParameterNotation}Offset"; - return $"SELECT * FROM {_sqlConfiguration.SchemaName}.[SystemAudits] WHERE [UserId] = {_sqlConfiguration.ParameterNotation}UserId AND [LoggedOn] >= {_sqlConfiguration.ParameterNotation}StartDate AND [LoggedOn] < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY [LoggedOn] DESC OFFSET {_sqlConfiguration.ParameterNotation}Offset ROWS FETCH NEXT {_sqlConfiguration.ParameterNotation}PageSize ROWS ONLY"; + return $"SELECT * FROM {_sqlConfiguration.SchemaName}.[SystemAudits] WHERE [UserId] = {_sqlConfiguration.ParameterNotation}UserId AND [LoggedOn] >= {_sqlConfiguration.ParameterNotation}StartDate AND [LoggedOn] < {_sqlConfiguration.ParameterNotation}EndDate ORDER BY [LoggedOn] DESC, [SystemAuditId] DESC OFFSET {_sqlConfiguration.ParameterNotation}Offset ROWS FETCH NEXT {_sqlConfiguration.ParameterNotation}PageSize ROWS ONLY"; } public string GetQuery() where TEntity : class, IEntity => GetQuery(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs index 63719cc51..01f4cf822 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs @@ -15,6 +15,11 @@ namespace Resgrid.Repositories.DataRepository { public class SystemAuditsRepository : RepositoryBase, ISystemAuditsRepository { + // Callers control page/pageSize; without a ceiling one request could pull the whole audit + // table, and (page - 1) * pageSize in int arithmetic can overflow into a negative OFFSET + // the database rejects. + private const int MaxPageSize = 1000; + private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; @@ -39,9 +44,8 @@ public async Task> GetByUserIdPagedAsync(string userId, dynamicParameters.Add("UserId", userId); dynamicParameters.Add("StartDate", startDate); dynamicParameters.Add("EndDate", endDate); - var safePage = page < 1 ? 1 : page; - var safePageSize = pageSize < 1 ? 1 : pageSize; - dynamicParameters.Add("Offset", (safePage - 1) * safePageSize); + var (offset, safePageSize) = NormalizePaging(page, pageSize); + dynamicParameters.Add("Offset", offset); dynamicParameters.Add("PageSize", safePageSize); var query = _queryFactory.GetQuery(); @@ -86,9 +90,8 @@ public async Task> GetByDepartmentIdPagedAsync(int depa dynamicParameters.Add("DepartmentId", departmentId); dynamicParameters.Add("StartDate", startDate); dynamicParameters.Add("EndDate", endDate); - var safePage = page < 1 ? 1 : page; - var safePageSize = pageSize < 1 ? 1 : pageSize; - dynamicParameters.Add("Offset", (safePage - 1) * safePageSize); + var (offset, safePageSize) = NormalizePaging(page, pageSize); + dynamicParameters.Add("Offset", offset); dynamicParameters.Add("PageSize", safePageSize); var query = _queryFactory.GetQuery(); @@ -133,9 +136,8 @@ public async Task> GetByTypePagedAsync(int type, DateTi dynamicParameters.Add("Type", type); dynamicParameters.Add("StartDate", startDate); dynamicParameters.Add("EndDate", endDate); - var safePage = page < 1 ? 1 : page; - var safePageSize = pageSize < 1 ? 1 : pageSize; - dynamicParameters.Add("Offset", (safePage - 1) * safePageSize); + var (offset, safePageSize) = NormalizePaging(page, pageSize); + dynamicParameters.Add("Offset", offset); dynamicParameters.Add("PageSize", safePageSize); var query = _queryFactory.GetQuery(); @@ -169,5 +171,13 @@ public async Task> GetByTypePagedAsync(int type, DateTi throw; } } + + private static (long Offset, int PageSize) NormalizePaging(int page, int pageSize) + { + var safePage = page < 1 ? 1 : page; + var safePageSize = pageSize < 1 ? 1 : (pageSize > MaxPageSize ? MaxPageSize : pageSize); + + return ((safePage - 1L) * safePageSize, safePageSize); + } } } diff --git a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs index 14f9280c8..44336b199 100644 --- a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs @@ -28,6 +28,7 @@ public class ChatIncidentBackfillTests private Mock _channelRepository; private Mock _cacheProvider; + private Mock _permissionService; private List _inserted; [SetUp] @@ -35,6 +36,7 @@ public void Setup() { _channelRepository = new Mock(); _cacheProvider = new Mock(); + _permissionService = new Mock(); _inserted = new List(); // No marker set: the backfill runs. @@ -56,7 +58,7 @@ private ChatChannelService BuildService() Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of(), + _permissionService.Object, Mock.Of(), Mock.Of(), Mock.Of(), @@ -187,5 +189,46 @@ public async Task a_command_without_a_call_is_ignored() _inserted.Should().BeEmpty(); } + + [Test] + public async Task channels_reused_from_a_prior_command_are_rebound_and_unarchived() + { + // Command #1 closed (its channels archived and still carrying its id), then command #2 + // established on the same call. The reused channels must come back to life under command #2. + var archivedOn = DateTime.UtcNow.AddHours(-2); + GivenExistingChannels( + new ChatChannel { ChatChannelId = "a", CallId = CallId, ChannelType = (int)ChatChannelType.Incident }, + new ChatChannel { ChatChannelId = "b", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentCommand, IncidentCommandId = "command-0", IsArchived = true, ArchivedOn = archivedOn }, + new ChatChannel { ChatChannelId = "c", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentLeads, IncidentCommandId = "command-0", IsArchived = true, ArchivedOn = archivedOn }, + new ChatChannel { ChatChannelId = "d", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentDispatch, IncidentCommandId = "command-0", IsArchived = true, ArchivedOn = archivedOn }); + + await BuildService().EnsureIncidentChannelsAsync(BuildCommand(), new CommandStructureNode[0]); + + _inserted.Should().BeEmpty(); + _channelRepository.Verify(x => x.RebindToIncidentCommandAsync("b", CommandId, It.IsAny(), It.IsAny()), Times.Once); + _channelRepository.Verify(x => x.RebindToIncidentCommandAsync("c", CommandId, It.IsAny(), It.IsAny()), Times.Once); + _channelRepository.Verify(x => x.RebindToIncidentCommandAsync("d", CommandId, It.IsAny(), It.IsAny()), Times.Once); + + // Archived state gates posting through cached permission verdicts — stale entries must die now. + _permissionService.Verify(x => x.InvalidateChannelCacheAsync("b"), Times.Once); + _permissionService.Verify(x => x.InvalidateChannelCacheAsync("c"), Times.Once); + _permissionService.Verify(x => x.InvalidateChannelCacheAsync("d"), Times.Once); + } + + [Test] + public async Task channels_already_bound_to_the_active_command_are_not_rewritten() + { + // The steady state — same command, nothing archived — must stay a pure read. + GivenExistingChannels( + new ChatChannel { ChatChannelId = "a", CallId = CallId, ChannelType = (int)ChatChannelType.Incident }, + new ChatChannel { ChatChannelId = "b", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentCommand, IncidentCommandId = CommandId }, + new ChatChannel { ChatChannelId = "c", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentLeads, IncidentCommandId = CommandId }, + new ChatChannel { ChatChannelId = "d", CallId = CallId, ChannelType = (int)ChatChannelType.IncidentDispatch, IncidentCommandId = CommandId }); + + await BuildService().EnsureIncidentChannelsAsync(BuildCommand(), new CommandStructureNode[0]); + + _inserted.Should().BeEmpty(); + _channelRepository.Verify(x => x.RebindToIncidentCommandAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } } } diff --git a/Tests/Resgrid.Tests/Services/DispatchAccessServiceTests.cs b/Tests/Resgrid.Tests/Services/DispatchAccessServiceTests.cs index 64a3d33c4..c34960550 100644 --- a/Tests/Resgrid.Tests/Services/DispatchAccessServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DispatchAccessServiceTests.cs @@ -110,6 +110,33 @@ public async Task someone_who_is_not_a_member_of_the_department_is_refused() result.Should().BeFalse(); } + [Test] + public async Task a_non_member_is_refused_even_when_the_permission_is_not_configured() + { + // The open default means "everyone in the department", never "everyone on the platform" — + // this gate is asked about a channel's department, not necessarily the caller's own. + GivenPermission(null); + _departmentsService.Setup(x => x.GetDepartmentMemberAsync("stranger", DepartmentId, It.IsAny())) + .ReturnsAsync((DepartmentMember)null); + + var result = await BuildService().CanUseDispatchAsync(DepartmentId, "stranger"); + + result.Should().BeFalse(); + } + + [TestCase(true, false)] + [TestCase(false, true)] + public async Task a_disabled_or_deleted_member_is_refused_even_when_the_permission_is_not_configured(bool disabled, bool deleted) + { + GivenPermission(null); + _departmentsService.Setup(x => x.GetDepartmentMemberAsync("former", DepartmentId, It.IsAny())) + .ReturnsAsync(new DepartmentMember { UserId = "former", DepartmentId = DepartmentId, IsDisabled = disabled, IsDeleted = deleted }); + + var result = await BuildService().CanUseDispatchAsync(DepartmentId, "former"); + + result.Should().BeFalse(); + } + [Test] public async Task an_evaluation_failure_fails_closed() { diff --git a/Tests/Resgrid.Tests/Web/Services/RequiresIncidentCapabilityFilterTests.cs b/Tests/Resgrid.Tests/Web/Services/RequiresIncidentCapabilityFilterTests.cs index 55a558efa..f88bd9926 100644 --- a/Tests/Resgrid.Tests/Web/Services/RequiresIncidentCapabilityFilterTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/RequiresIncidentCapabilityFilterTests.cs @@ -196,11 +196,15 @@ private ActionExecutingContext BuildContext( IDictionary args, IDictionary routeValues = null, bool authenticated = true, - ICommandAccessService commandAccessService = null) + ICommandAccessService commandAccessService = null, + bool withoutCommandAccessService = false) { + // Tests that don't exercise the commander gate get an allow-all gate so they keep testing + // only capabilities; withoutCommandAccessService simulates a misconfigured host (filter must 500). var httpContext = new DefaultHttpContext { - RequestServices = new StubServiceProvider(_service.Object, commandAccessService) + RequestServices = new StubServiceProvider(_service.Object, + withoutCommandAccessService ? null : commandAccessService ?? CommandGate(true)) }; if (authenticated) @@ -254,8 +258,6 @@ public object GetService(Type serviceType) if (serviceType == typeof(IIncidentCommandService)) return _service; - // Null when a test doesn't supply one — the filter then skips the commander gate, which is - // what keeps the pre-existing capability tests exercising only capabilities. if (serviceType == typeof(ICommandAccessService)) return _commandAccessService; @@ -274,6 +276,25 @@ private static ICommandAccessService CommandGate(bool allowed) #region Commander permission gate + [Test] + public async Task Returns500_WhenTheCommanderGateServiceCannotBeResolved() + { + // The department gate is mandatory; a host that cannot resolve it is misconfigured and must + // deny loudly instead of silently dropping the check. + var filter = new RequiresIncidentCapabilityAttribute(IncidentCapabilities.AssignResources); + var context = BuildContext( + args: new Dictionary { ["callId"] = 7 }, + routeValues: new Dictionary { ["callId"] = 7 }, + withoutCommandAccessService: true); + + var nextCalled = await Invoke(filter, context); + + nextCalled.Should().BeFalse(); + context.Result.Should().BeOfType() + .Which.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + _service.Verify(s => s.GetCapabilitiesForUserAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task Returns403_WhenTheDepartmentHasNotAuthorizedTheUserAsACommander() { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs index c1c8d8325..3c09d7436 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs @@ -101,6 +101,9 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService, [Authorize(Policy = ResgridResources.Command_View)] public async Task> GetCommandForCall(int callId) { + if (!await CanReadBoardsAsync()) + return Unauthorized(); + var result = new ICModels.IncidentCommandResult(); var command = await _incidentCommandService.GetCommandForCallAsync(DepartmentId, callId); @@ -131,6 +134,9 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService, if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId)) return BadRequest(); + if (!await CanCommandAsync()) + return Unauthorized(); + var result = new ICModels.IncidentCommandResult(); try { @@ -274,6 +280,9 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService, if (input == null || input.CallId <= 0 || string.IsNullOrWhiteSpace(input.Body)) return BadRequest(); + if (!await CanCommandAsync()) + return Unauthorized(); + var result = new ICModels.SendMessageToCommandResult(); var command = await _incidentCommandService.GetCommandForCallAsync(DepartmentId, input.CallId); diff --git a/Web/Resgrid.Web.Services/Filters/RequiresIncidentCapabilityAttribute.cs b/Web/Resgrid.Web.Services/Filters/RequiresIncidentCapabilityAttribute.cs index 4f1dee3f2..0b465b429 100644 --- a/Web/Resgrid.Web.Services/Filters/RequiresIncidentCapabilityAttribute.cs +++ b/Web/Resgrid.Web.Services/Filters/RequiresIncidentCapabilityAttribute.cs @@ -60,7 +60,16 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE // because a member the department hasn't authorized as a commander shouldn't reach the board // surface whatever ICS role happens to be recorded against them. var commandAccess = context.HttpContext.RequestServices?.GetService(typeof(ICommandAccessService)) as ICommandAccessService; - if (commandAccess != null && !await commandAccess.CanUseCommandAsync(departmentId, userId)) + if (commandAccess == null) + { + // This gate is mandatory on the board surface. Unresolvable here means the host is + // misconfigured — deny loudly rather than silently dropping a security check while the + // capability evaluation carries on without it. + context.Result = new StatusCodeResult(StatusCodes.Status500InternalServerError); + return; + } + + if (!await commandAccess.CanUseCommandAsync(departmentId, userId)) { context.Result = new ObjectResult("You are not authorized to work incident command for this department.") { diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml index 389501f3f..291143291 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml @@ -314,8 +314,8 @@ - Use Calendar Sync - Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. + @localizer["PermUseCalendarSyncLabel"] + @localizer["PermUseCalendarSyncNote"] @Html.DropDownListFor(m => m.UseCalendarSync, Model.UseCalendarSyncPermissions) @localizer["PermissionNA"] @@ -324,8 +324,8 @@ - Dispatch App Login - Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. + @localizer["PermDispatchAppLoginLabel"] + @localizer["PermDispatchAppLoginNote"] @Html.DropDownListFor(m => m.DispatchAppLogin, Model.DispatchAppLoginPermissions) @localizer["PermissionNA"] @@ -334,8 +334,8 @@ - Command App Login - Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. + @localizer["PermCommandAppLoginLabel"] + @localizer["PermCommandAppLoginNote"] @Html.DropDownListFor(m => m.CommandAppLogin, Model.CommandAppLoginPermissions) @localizer["PermissionNA"] From c6c7511aef999626e7349946c1395fccc18b8278 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 10 Aug 2026 18:08:54 -0700 Subject: [PATCH 5/6] RD-T42 API CORS fix --- Core/Resgrid.Config/ApiConfig.cs | 10 ++ Core/Resgrid.Config/CorsHelper.cs | 139 +++++++++++++++++ Tests/Resgrid.Tests/Config/CorsHelperTests.cs | 140 ++++++++++++++++++ Web/Resgrid.Web.Eventing/Startup.cs | 31 +--- Web/Resgrid.Web.Services/Startup.cs | 40 +---- 5 files changed, 297 insertions(+), 63 deletions(-) create mode 100644 Core/Resgrid.Config/CorsHelper.cs create mode 100644 Tests/Resgrid.Tests/Config/CorsHelperTests.cs diff --git a/Core/Resgrid.Config/ApiConfig.cs b/Core/Resgrid.Config/ApiConfig.cs index 4adc192c7..421a7bfe1 100644 --- a/Core/Resgrid.Config/ApiConfig.cs +++ b/Core/Resgrid.Config/ApiConfig.cs @@ -15,6 +15,16 @@ public static class ApiConfig /// public const string CorsAllowedMethods = "GET,POST,PUT,DELETE,OPTIONS"; + /// + /// Comma-separated list of additional origins allowed to make cross-origin (CORS) requests + /// 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 + /// isolated on-prem or development installs. + /// + public static string CorsAllowedOrigins = ""; + /// /// Key used for authing with the backend internal apis /// diff --git a/Core/Resgrid.Config/CorsHelper.cs b/Core/Resgrid.Config/CorsHelper.cs new file mode 100644 index 000000000..fb945ecdb --- /dev/null +++ b/Core/Resgrid.Config/CorsHelper.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Config +{ + /// + /// Shared CORS origin validation used by the web front-ends (Services API and Eventing/SignalR). + /// 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. + /// 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 + /// lets sibling apps call the API without being listed explicitly: with a base url of + /// qaapi.resgrid.dev the parent is resgrid.dev, so qadispatch.resgrid.dev is allowed. + /// Parent widening never crosses a public registry suffix (resgrid.co.uk will not widen + /// to co.uk) and is skipped entirely for IP addresses and single-label hosts. + /// + public static class CorsHelper + { + // Multi-part public registry suffixes that must never be treated as a shared parent + // domain. Widening api.resgrid.co.uk to co.uk would allow every site registered under + // that suffix to make credentialed calls, so parent widening stops just short of these. + // Single-part TLDs (com, dev, net, ...) need no listing: widening already stops at two + // labels, so a bare TLD can never be produced as a parent. + private static readonly HashSet _publicRegistrySuffixes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "co.uk", "org.uk", "me.uk", "ltd.uk", "plc.uk", "net.uk", "sch.uk", "ac.uk", "gov.uk", "nhs.uk", + "com.au", "net.au", "org.au", "edu.au", "gov.au", "id.au", "asn.au", + "co.nz", "net.nz", "org.nz", "govt.nz", "ac.nz", + "co.jp", "ne.jp", "or.jp", "go.jp", "ac.jp", + "com.br", "net.br", "org.br", "gov.br", + "com.mx", "org.mx", "gob.mx", + "co.za", "org.za", "gov.za", "web.za", + "co.in", "net.in", "org.in", "gen.in", "firm.in", "ind.in", + "com.cn", "net.cn", "org.cn", "gov.cn", + "com.sg", "com.hk", "com.tw", "com.my", "com.ph", "com.tr", "com.ar", "com.co", + "co.id", "co.kr", "co.th", "co.il" + }; + + /// + /// Returns true when the supplied Origin header value is allowed to make cross-origin + /// requests. Suitable for use with CorsPolicyBuilder.SetIsOriginAllowed, including + /// policies that also call AllowCredentials (the matched origin is echoed back, never "*"). + /// + public static bool IsAllowedOrigin(string origin) + { + if (String.IsNullOrWhiteSpace(origin) || !Uri.TryCreate(origin, UriKind.Absolute, out var originUri) || String.IsNullOrWhiteSpace(originUri.Host)) + return false; + + if (MatchesConfiguredOrigin(originUri)) + return true; + + foreach (var baseUrl in new[] + { + SystemBehaviorConfig.ResgridBaseUrl, + SystemBehaviorConfig.ResgridApiBaseUrl, + SystemBehaviorConfig.ResgridEventingBaseUrl + }) + { + if (String.IsNullOrWhiteSpace(baseUrl) || !Uri.TryCreate(baseUrl, UriKind.Absolute, out var baseUri) || String.IsNullOrWhiteSpace(baseUri.Host)) + continue; + + if (HostMatchesOrIsSubdomainOf(originUri.Host, baseUri.Host)) + return true; + + var parentDomain = GetWidestSafeParentDomain(baseUri); + if (parentDomain != null && HostMatchesOrIsSubdomainOf(originUri.Host, parentDomain)) + return true; + } + + return false; + } + + private static bool MatchesConfiguredOrigin(Uri originUri) + { + 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 == "*") + return true; + + if (entry.Contains("://")) + { + if (Uri.TryCreate(entry, UriKind.Absolute, out var entryUri) && + String.Equals(originUri.Scheme, entryUri.Scheme, StringComparison.OrdinalIgnoreCase) && + String.Equals(originUri.Host, entryUri.Host, StringComparison.OrdinalIgnoreCase) && + originUri.Port == entryUri.Port) + return true; + } + else if (String.Equals(originUri.Host, entry, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static bool HostMatchesOrIsSubdomainOf(string originHost, string allowedHost) + { + return originHost.Equals(allowedHost, StringComparison.OrdinalIgnoreCase) || + originHost.EndsWith("." + allowedHost, StringComparison.OrdinalIgnoreCase); + } + + private static string GetWidestSafeParentDomain(Uri baseUri) + { + if (baseUri.HostNameType != UriHostNameType.Dns) + return null; + + var labels = baseUri.Host.Split('.'); + + // Walk from the full host toward the apex (never past two labels), stopping before + // any public registry suffix; the last safe candidate is the widest usable parent. + // qaapi.resgrid.dev -> resgrid.dev; api.resgrid.co.uk -> resgrid.co.uk (co.uk unsafe); + // resgrid.com / localhost -> null (base-host matching already covers them). + string widest = null; + for (int start = 1; start <= labels.Length - 2; start++) + { + var candidate = String.Join(".", labels, start, labels.Length - start); + if (_publicRegistrySuffixes.Contains(candidate)) + break; + + widest = candidate; + } + + return widest; + } + } +} diff --git a/Tests/Resgrid.Tests/Config/CorsHelperTests.cs b/Tests/Resgrid.Tests/Config/CorsHelperTests.cs new file mode 100644 index 000000000..c6022d5f1 --- /dev/null +++ b/Tests/Resgrid.Tests/Config/CorsHelperTests.cs @@ -0,0 +1,140 @@ +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Config; + +namespace Resgrid.Tests.Config +{ + [TestFixture] + public class CorsHelperTests + { + private string _originalBaseUrl; + private string _originalApiBaseUrl; + private string _originalEventingBaseUrl; + private string _originalCorsAllowedOrigins; + + [SetUp] + public void SetUp() + { + _originalBaseUrl = SystemBehaviorConfig.ResgridBaseUrl; + _originalApiBaseUrl = SystemBehaviorConfig.ResgridApiBaseUrl; + _originalEventingBaseUrl = SystemBehaviorConfig.ResgridEventingBaseUrl; + _originalCorsAllowedOrigins = ApiConfig.CorsAllowedOrigins; + + SystemBehaviorConfig.ResgridBaseUrl = "https://qaweb.resgrid.dev"; + SystemBehaviorConfig.ResgridApiBaseUrl = "https://qaapi.resgrid.dev"; + SystemBehaviorConfig.ResgridEventingBaseUrl = "https://qaevents.resgrid.dev"; + ApiConfig.CorsAllowedOrigins = ""; + } + + [TearDown] + public void TearDown() + { + SystemBehaviorConfig.ResgridBaseUrl = _originalBaseUrl; + SystemBehaviorConfig.ResgridApiBaseUrl = _originalApiBaseUrl; + SystemBehaviorConfig.ResgridEventingBaseUrl = _originalEventingBaseUrl; + ApiConfig.CorsAllowedOrigins = _originalCorsAllowedOrigins; + } + + [Test] + public void should_allow_configured_base_hosts_and_their_subdomains() + { + CorsHelper.IsAllowedOrigin("https://qaapi.resgrid.dev").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://sub.qaweb.resgrid.dev").Should().BeTrue(); + } + + [Test] + public void should_allow_sibling_apps_under_the_shared_parent_domain() + { + // qadispatch.resgrid.dev is not a subdomain of any configured base host, but it + // shares the resgrid.dev parent - this is the dispatch/unit/responder web case. + CorsHelper.IsAllowedOrigin("https://qadispatch.resgrid.dev").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://resgrid.dev").Should().BeTrue(); + } + + [Test] + public void should_allow_subdomains_when_base_url_is_already_the_apex() + { + SystemBehaviorConfig.ResgridBaseUrl = "https://resgrid.com"; + SystemBehaviorConfig.ResgridApiBaseUrl = "https://api.resgrid.com"; + SystemBehaviorConfig.ResgridEventingBaseUrl = "https://events.resgrid.com"; + + CorsHelper.IsAllowedOrigin("https://dispatch.resgrid.com").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://resgrid.com").Should().BeTrue(); + } + + [Test] + public void should_reject_unrelated_and_lookalike_domains() + { + CorsHelper.IsAllowedOrigin("https://evil.com").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("https://evilresgrid.dev").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("https://resgrid.dev.evil.com").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("https://qaapi.resgrid.dev.evil.com").Should().BeFalse(); + } + + [Test] + public void should_not_widen_the_parent_domain_past_a_public_registry_suffix() + { + SystemBehaviorConfig.ResgridBaseUrl = "https://web.resgrid.co.uk"; + SystemBehaviorConfig.ResgridApiBaseUrl = "https://api.resgrid.co.uk"; + SystemBehaviorConfig.ResgridEventingBaseUrl = "https://events.resgrid.co.uk"; + + // Siblings under resgrid.co.uk are fine, but the parent must never widen to co.uk. + CorsHelper.IsAllowedOrigin("https://dispatch.resgrid.co.uk").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://someoneelse.co.uk").Should().BeFalse(); + } + + [Test] + public void should_not_widen_single_label_or_ip_hosts() + { + SystemBehaviorConfig.ResgridBaseUrl = "https://localhost"; + SystemBehaviorConfig.ResgridApiBaseUrl = "https://192.168.1.20"; + SystemBehaviorConfig.ResgridEventingBaseUrl = ""; + + CorsHelper.IsAllowedOrigin("https://localhost").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://192.168.1.20").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://192.168.1.21").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("https://example.com").Should().BeFalse(); + } + + [Test] + public void should_match_configured_origins_with_a_scheme_exactly() + { + ApiConfig.CorsAllowedOrigins = "http://localhost:8081, https://mydispatch.example.com"; + + CorsHelper.IsAllowedOrigin("http://localhost:8081").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("http://localhost:9999").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("https://localhost:8081").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("https://mydispatch.example.com").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("http://mydispatch.example.com").Should().BeFalse(); + } + + [Test] + public void should_match_bare_host_entries_on_any_scheme_and_port() + { + ApiConfig.CorsAllowedOrigins = "mydispatch.example.com"; + + CorsHelper.IsAllowedOrigin("https://mydispatch.example.com").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("http://mydispatch.example.com:3000").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://sub.mydispatch.example.com").Should().BeFalse(); + } + + [Test] + public void should_allow_everything_with_a_wildcard_entry() + { + ApiConfig.CorsAllowedOrigins = "*"; + + CorsHelper.IsAllowedOrigin("https://anything.example.com").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("http://localhost:1234").Should().BeTrue(); + } + + [Test] + public void should_reject_missing_or_malformed_origins() + { + CorsHelper.IsAllowedOrigin(null).Should().BeFalse(); + CorsHelper.IsAllowedOrigin("").Should().BeFalse(); + CorsHelper.IsAllowedOrigin(" ").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("not-a-url").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("null").Should().BeFalse(); + } + } +} diff --git a/Web/Resgrid.Web.Eventing/Startup.cs b/Web/Resgrid.Web.Eventing/Startup.cs index 2bcce853e..f46f5546e 100644 --- a/Web/Resgrid.Web.Eventing/Startup.cs +++ b/Web/Resgrid.Web.Eventing/Startup.cs @@ -371,10 +371,13 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) //app.UseHttpsRedirection(); + // global cors policy: the configured Resgrid base hosts, their subdomains, sibling apps + // under the same parent domain (dispatch.resgrid.com alongside events.resgrid.com) and any + // extra origins in ApiConfig.CorsAllowedOrigins. See Resgrid.Config.CorsHelper. app.UseCors(x => x .AllowAnyMethod() .AllowAnyHeader() - .SetIsOriginAllowed(IsAllowedOrigin) + .SetIsOriginAllowed(CorsHelper.IsAllowedOrigin) .AllowCredentials()); // allow credentials app.UseAuthentication(); @@ -398,31 +401,5 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) }); } - private static bool IsAllowedOrigin(string origin) - { - if (string.IsNullOrWhiteSpace(origin) || !Uri.TryCreate(origin, UriKind.Absolute, out var originUri)) - return false; - - var configuredBaseUrls = new[] - { - SystemBehaviorConfig.ResgridBaseUrl, - SystemBehaviorConfig.ResgridApiBaseUrl, - SystemBehaviorConfig.ResgridEventingBaseUrl - }; - - foreach (var baseUrl in configuredBaseUrls) - { - if (string.IsNullOrWhiteSpace(baseUrl) || !Uri.TryCreate(baseUrl, UriKind.Absolute, out var baseUri)) - continue; - - if (string.Equals(originUri.Host, baseUri.Host, StringComparison.OrdinalIgnoreCase)) - return true; - - if (originUri.Host.EndsWith("." + baseUri.Host, StringComparison.OrdinalIgnoreCase)) - return true; - } - - return false; - } } } diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 2265a0f46..24233362e 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -710,13 +710,13 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerF twilioApp => twilioApp.UseTwilioRequestValidation()); //app.UseCors("_resgridWebsiteAllowSpecificOrigins"); - // global cors policy: only the configured Resgrid base hosts (and their subdomains) may call - // credentialed endpoints. Derived from SystemBehaviorConfig base URLs. - var allowedCorsHosts = GetAllowedCorsHosts(); + // global cors policy: the configured Resgrid base hosts, their subdomains, sibling apps + // under the same parent domain (dispatch.resgrid.com alongside api.resgrid.com) and any + // extra origins in ApiConfig.CorsAllowedOrigins. See Resgrid.Config.CorsHelper. app.UseCors(x => x .AllowAnyMethod() .AllowAnyHeader() - .SetIsOriginAllowed(origin => IsAllowedCorsOrigin(origin, allowedCorsHosts)) + .SetIsOriginAllowed(CorsHelper.IsAllowedOrigin) .AllowCredentials()); // allow credentials app.UseRouting(); @@ -778,37 +778,5 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerF }); } - private static HashSet GetAllowedCorsHosts() - { - var hosts = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var url in new[] - { - Config.SystemBehaviorConfig.ResgridBaseUrl, - Config.SystemBehaviorConfig.ResgridApiBaseUrl, - Config.SystemBehaviorConfig.ResgridEventingBaseUrl - }) - { - if (!String.IsNullOrWhiteSpace(url) && Uri.TryCreate(url, UriKind.Absolute, out var uri) && !String.IsNullOrWhiteSpace(uri.Host)) - hosts.Add(uri.Host); - } - - return hosts; - } - - private static bool IsAllowedCorsOrigin(string origin, HashSet allowedHosts) - { - if (String.IsNullOrWhiteSpace(origin) || !Uri.TryCreate(origin, UriKind.Absolute, out var uri)) - return false; - - foreach (var host in allowedHosts) - { - if (uri.Host.Equals(host, StringComparison.OrdinalIgnoreCase) || - uri.Host.EndsWith($".{host}", StringComparison.OrdinalIgnoreCase)) - return true; - } - - return false; - } } } From cc03635a291407d593b0683cb32580dd72331aff Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 10 Aug 2026 22:19:38 -0700 Subject: [PATCH 6/6] RG-T117 CORS minor fix --- Core/Resgrid.Config/CorsHelper.cs | 58 +++++++++++++++---- Tests/Resgrid.Tests/Config/CorsHelperTests.cs | 29 ++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/Core/Resgrid.Config/CorsHelper.cs b/Core/Resgrid.Config/CorsHelper.cs index fb945ecdb..5f180c980 100644 --- a/Core/Resgrid.Config/CorsHelper.cs +++ b/Core/Resgrid.Config/CorsHelper.cs @@ -16,17 +16,29 @@ namespace Resgrid.Config /// lets sibling apps call the API without being listed explicitly: with a base url of /// qaapi.resgrid.dev the parent is resgrid.dev, so qadispatch.resgrid.dev is allowed. /// Parent widening never crosses a public registry suffix (resgrid.co.uk will not widen - /// to co.uk) and is skipped entirely for IP addresses and single-label hosts. + /// to co.uk) or a known shared-hosting suffix (myorg.github.io will not widen to + /// github.io) and is skipped entirely for IP addresses and single-label hosts. /// public static class CorsHelper { - // Multi-part public registry suffixes that must never be treated as a shared parent - // domain. Widening api.resgrid.co.uk to co.uk would allow every site registered under - // that suffix to make credentialed calls, so parent widening stops just short of these. - // Single-part TLDs (com, dev, net, ...) need no listing: widening already stops at two - // labels, so a bare TLD can never be produced as a parent. - private static readonly HashSet _publicRegistrySuffixes = new HashSet(StringComparer.OrdinalIgnoreCase) + // Suffixes that must never be treated as a shared parent domain, because mutually + // untrusting parties register siblings directly under them. Two kinds live here: + // + // 1. Multi-part public registry suffixes (co.uk, com.au, ...). Widening api.resgrid.co.uk + // to co.uk would allow every site registered under that suffix to make credentialed + // calls. Single-part TLDs (com, dev, net, ...) need no listing: widening already stops + // at two labels, so a bare TLD can never be produced as a parent. + // 2. Private shared-hosting suffixes (github.io, azurewebsites.net, herokuapp.com, ...). + // A deployment served from myorg.github.io must not widen to github.io — every other + // tenant on the platform is an attacker-controlled sibling. Widening still works one + // level below the suffix (api.myorg.github.io widens to myorg.github.io). + // + // This is a curated snapshot of the common cases, not the full Public Suffix List. A + // deployment under a suffix not listed here should not rely on parent widening at all — + // list its sibling origins explicitly in ApiConfig.CorsAllowedOrigins instead. + private static readonly HashSet _unsafeParentSuffixes = new HashSet(StringComparer.OrdinalIgnoreCase) { + // Public registry suffixes. "co.uk", "org.uk", "me.uk", "ltd.uk", "plc.uk", "net.uk", "sch.uk", "ac.uk", "gov.uk", "nhs.uk", "com.au", "net.au", "org.au", "edu.au", "gov.au", "id.au", "asn.au", "co.nz", "net.nz", "org.nz", "govt.nz", "ac.nz", @@ -37,7 +49,30 @@ public static class CorsHelper "co.in", "net.in", "org.in", "gen.in", "firm.in", "ind.in", "com.cn", "net.cn", "org.cn", "gov.cn", "com.sg", "com.hk", "com.tw", "com.my", "com.ph", "com.tr", "com.ar", "com.co", - "co.id", "co.kr", "co.th", "co.il" + "co.id", "co.kr", "co.th", "co.il", + + // Private shared-hosting suffixes: code hosting pages. + "github.io", "gitlab.io", "bitbucket.io", + + // Microsoft Azure. + "azurewebsites.net", "azurestaticapps.net", "azurecontainerapps.io", "cloudapp.net", + "cloudapp.azure.com", "trafficmanager.net", "azureedge.net", "azurefd.net", + + // Amazon AWS (amazonaws.com blankets S3/ELB/execute-api regional hosts). + "amazonaws.com", "cloudfront.net", "elasticbeanstalk.com", "amplifyapp.com", "awsapprunner.com", + + // Google Cloud / Firebase. + "appspot.com", "web.app", "firebaseapp.com", "run.app", + + // Cloudflare. + "pages.dev", "workers.dev", "r2.dev", "trycloudflare.com", + + // Other common PaaS / static hosting / tunnels. + "herokuapp.com", "netlify.app", "vercel.app", "now.sh", "surge.sh", "glitch.me", + "onrender.com", "fly.dev", "railway.app", "deno.dev", "koyeb.app", + "ondigitalocean.app", "digitaloceanspaces.com", + "repl.co", "replit.app", + "ngrok.io", "ngrok.app", "ngrok-free.app", "ngrok.dev", "loca.lt" }; /// @@ -120,14 +155,15 @@ private static string GetWidestSafeParentDomain(Uri baseUri) var labels = baseUri.Host.Split('.'); // Walk from the full host toward the apex (never past two labels), stopping before - // any public registry suffix; the last safe candidate is the widest usable parent. - // qaapi.resgrid.dev -> resgrid.dev; api.resgrid.co.uk -> resgrid.co.uk (co.uk unsafe); + // any public registry or shared-hosting suffix; the last safe candidate is the widest + // usable parent. qaapi.resgrid.dev -> resgrid.dev; api.resgrid.co.uk -> resgrid.co.uk + // (co.uk unsafe); api.myorg.github.io -> myorg.github.io (github.io unsafe); // resgrid.com / localhost -> null (base-host matching already covers them). string widest = null; for (int start = 1; start <= labels.Length - 2; start++) { var candidate = String.Join(".", labels, start, labels.Length - start); - if (_publicRegistrySuffixes.Contains(candidate)) + if (_unsafeParentSuffixes.Contains(candidate)) break; widest = candidate; diff --git a/Tests/Resgrid.Tests/Config/CorsHelperTests.cs b/Tests/Resgrid.Tests/Config/CorsHelperTests.cs index c6022d5f1..6a2ef61a1 100644 --- a/Tests/Resgrid.Tests/Config/CorsHelperTests.cs +++ b/Tests/Resgrid.Tests/Config/CorsHelperTests.cs @@ -83,6 +83,35 @@ public void should_not_widen_the_parent_domain_past_a_public_registry_suffix() CorsHelper.IsAllowedOrigin("https://someoneelse.co.uk").Should().BeFalse(); } + [Test] + public void should_not_widen_the_parent_domain_past_a_shared_hosting_suffix() + { + // A deployment on shared hosting must not treat the platform apex as its parent — + // every other tenant is an attacker-controlled sibling. Widening still works one + // level below the suffix, scoped to the deployment's own tenant name. + SystemBehaviorConfig.ResgridBaseUrl = "https://myorg.github.io"; + SystemBehaviorConfig.ResgridApiBaseUrl = "https://api.myorg.github.io"; + SystemBehaviorConfig.ResgridEventingBaseUrl = ""; + + CorsHelper.IsAllowedOrigin("https://myorg.github.io").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://dispatch.myorg.github.io").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://attacker.github.io").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("https://github.io").Should().BeFalse(); + } + + [Test] + public void should_not_widen_the_parent_domain_past_a_paas_suffix() + { + SystemBehaviorConfig.ResgridBaseUrl = "https://resgrid-web.azurewebsites.net"; + SystemBehaviorConfig.ResgridApiBaseUrl = "https://resgrid-api.azurewebsites.net"; + SystemBehaviorConfig.ResgridEventingBaseUrl = "https://resgrid-app.herokuapp.com"; + + CorsHelper.IsAllowedOrigin("https://resgrid-web.azurewebsites.net").Should().BeTrue(); + CorsHelper.IsAllowedOrigin("https://attacker.azurewebsites.net").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("https://attacker.herokuapp.com").Should().BeFalse(); + CorsHelper.IsAllowedOrigin("https://azurewebsites.net").Should().BeFalse(); + } + [Test] public void should_not_widen_single_label_or_ip_hosts() {