From b8ec102401fcb8e33ab1554cff571ff476c9c74a Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 20 Aug 2026 15:44:08 -0700 Subject: [PATCH 1/3] RG-T133 Chat system hardening, audit log fixes --- Core/Resgrid.Model/Services/IChatServices.cs | 124 +++--- Core/Resgrid.Services/ChatChannelService.cs | 206 +++++++-- Core/Resgrid.Services/ChatMessageService.cs | 123 ++++-- .../Resgrid.Services/ChatModerationService.cs | 83 +++- .../Resgrid.Services/ChatPermissionService.cs | 396 +++++++++++++----- .../ChatProvisioningEventService.cs | 71 ++++ .../IncidentCommandService.cs | 2 +- Core/Resgrid.Services/ModerationService.cs | 10 +- .../PermissionGateServiceBase.cs | 7 +- Core/Resgrid.Services/PermissionsService.cs | 52 ++- Core/Resgrid.Services/PushService.cs | 62 ++- .../ChatAuthorizationPolicyExtensions.cs | 24 ++ .../NovuProvider.cs | 18 +- .../Services/ChatChannelServiceTests.cs | 255 ++++++++++- .../Services/ChatCommanderLineTests.cs | 76 +++- .../Services/ChatFrozenChannelTests.cs | 20 +- .../Services/ChatMessageServiceTests.cs | 98 ++++- .../Services/ChatPermissionServiceTests.cs | 311 +++++++++++++- .../Services/DispatchAccessServiceTests.cs | 13 + .../PushServiceModernApplicationSoundTests.cs | 3 +- .../PushServiceNotificationEventCodeTests.cs | 5 +- .../Web/Services/ChatAuthorizationTests.cs | 123 ++++++ .../ChatControllerCommanderLineTests.cs | 4 +- .../Web/User/SecurityControllerTests.cs | 197 +++++++++ Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs | 57 ++- Web/Resgrid.Web.Eventing/Startup.cs | 11 +- Web/Resgrid.Web.Eventing/Worker.cs | 66 ++- .../Controllers/ChatbotTelegramController.cs | 16 +- .../Controllers/v4/ChatController.cs | 168 ++++---- .../v4/ChatModerationController.cs | 30 +- .../Controllers/v4/ChatbotController.cs | 26 +- Web/Resgrid.Web.Services/Startup.cs | 2 +- .../User/Apps/src/components/chat/chatHub.ts | 32 +- .../User/Controllers/SecurityController.cs | 58 ++- .../User/Models/Security/AuditLogJson.cs | 5 +- .../User/Models/Security/ViewAuditLogView.cs | 1 + .../Areas/User/Views/Security/Audits.cshtml | 18 +- .../User/Views/Security/ViewAudit.cshtml | 151 ++++++- .../security/resgrid.security.audits.js | 69 ++- .../Logic/SystemQueueLogic.cs | 3 + 40 files changed, 2504 insertions(+), 492 deletions(-) create mode 100644 Providers/Resgrid.Providers.Claims/ChatAuthorizationPolicyExtensions.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/ChatAuthorizationTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/SecurityControllerTests.cs diff --git a/Core/Resgrid.Model/Services/IChatServices.cs b/Core/Resgrid.Model/Services/IChatServices.cs index 0420ced51..a6ce790da 100644 --- a/Core/Resgrid.Model/Services/IChatServices.cs +++ b/Core/Resgrid.Model/Services/IChatServices.cs @@ -8,7 +8,7 @@ namespace Resgrid.Model.Services /// /// Channel lifecycle: creation, membership, preferences and the idempotent Ensure* provisioning for /// default (department/group), incident (call/lane/command) and chatbot channels. - /// AUTHORIZATION: unless a method documents its own enforcement (AddMembersAsync, EnsureMemberStateAsync), + /// AUTHORIZATION: unless a method documents its own enforcement, /// the CALLER must verify access via IChatPermissionService before invoking these methods — the service /// executes, it does not gate reads. /// @@ -30,25 +30,25 @@ public interface IChatChannelService /// /// Finds or creates the 1:1 channel between the creator and a user or unit (DmKey dedup). - /// Enforces cross-tenant rules: the target user/unit must belong to the department + /// Enforces cross-tenant rules: the creator and target user/unit must belong to the department /// (UnauthorizedAccessException otherwise). The CALLER must verify the creator may open DMs. /// Task GetOrCreateDirectMessageChannelAsync(int departmentId, string creatorUserId, string targetUserId, int? targetUnitId, CancellationToken cancellationToken = default(CancellationToken)); /// - /// Creates an ad-hoc group channel. Enforces that every memberUserId belongs to the department + /// Creates an ad-hoc group channel. Enforces that the creator and every memberUserId belong to the department /// (UnauthorizedAccessException otherwise). The CALLER must verify the creator may create groups. /// Task CreateAdHocGroupChannelAsync(int departmentId, string creatorUserId, string name, List memberUserIds, CancellationToken cancellationToken = default(CancellationToken)); - /// Creates a permission-locked custom channel; rules are OR-evaluated (groups/roles/users). The CALLER must verify the creator may create custom channels. + /// Creates a permission-locked custom channel; rules are OR-evaluated (groups/roles/users). Enforces department membership and department-admin authority for the creator. Task CreateCustomChannelAsync(int departmentId, string creatorUserId, string name, string topic, List accessRules, CancellationToken cancellationToken = default(CancellationToken)); - /// Name/topic update; the CALLER must verify moderator rights (CanModerateChannelAsync) first. - Task UpdateChannelAsync(string chatChannelId, string name, string topic, string byUserId, CancellationToken cancellationToken = default(CancellationToken)); + /// Name/topic update; enforces the supplied department boundary and moderator rights internally. + Task UpdateChannelAsync(int departmentId, string chatChannelId, string name, string topic, string byUserId, CancellationToken cancellationToken = default(CancellationToken)); - /// Archive/unarchive; the CALLER must verify moderator rights first. - Task SetChannelArchivedAsync(string chatChannelId, bool archived, string byUserId, CancellationToken cancellationToken = default(CancellationToken)); + /// Archive/unarchive; enforces the supplied department boundary and moderator rights internally. + Task SetChannelArchivedAsync(int departmentId, string chatChannelId, bool archived, string byUserId, CancellationToken cancellationToken = default(CancellationToken)); /// Raw member list; the CALLER must verify the user can access the channel first. Task> GetMembersAsync(string chatChannelId); @@ -69,18 +69,16 @@ public interface IChatChannelService Task GetUnitMembershipAsync(string chatChannelId, int unitId); /// - /// Adds members. Enforcement inside: DirectMessage channels reject adds (InvalidOperationException), - /// CustomLocked channels require the actor to be a moderator (UnauthorizedAccessException), and every - /// userId must belong to the channel's department (UnauthorizedAccessException). Other channel types - /// rely on the CALLER to authorize the actor first. + /// Adds members. Enforces the supplied department boundary, supported channel types, actor membership + /// or moderator authority, and that every userId belongs to the channel's department. /// - Task> AddMembersAsync(string chatChannelId, List userIds, string addedByUserId, CancellationToken cancellationToken = default(CancellationToken)); + Task> AddMembersAsync(int departmentId, string chatChannelId, List userIds, string addedByUserId, CancellationToken cancellationToken = default(CancellationToken)); - /// Marks the member removed (leave or kick); history row kept. The CALLER must verify the actor is the member themselves or a moderator. - Task RemoveMemberAsync(string chatChannelId, string userId, string removedByUserId, CancellationToken cancellationToken = default(CancellationToken)); + /// Marks the member removed (leave or kick); enforces department scope and self-or-moderator authority internally. + Task RemoveMemberAsync(int departmentId, string chatChannelId, string userId, string removedByUserId, CancellationToken cancellationToken = default(CancellationToken)); - /// Replaces all access rules atomically; the CALLER must verify moderator rights first. - Task ReplaceAccessRulesAsync(string chatChannelId, List accessRules, string byUserId, CancellationToken cancellationToken = default(CancellationToken)); + /// Replaces all access rules atomically; enforces department scope and moderator rights internally. + Task ReplaceAccessRulesAsync(int departmentId, string chatChannelId, List accessRules, string byUserId, CancellationToken cancellationToken = default(CancellationToken)); /// /// Returns the participant's member row for the channel, lazily creating one for implicit-audience @@ -165,8 +163,8 @@ Task EnsureIncidentCommanderLineAsync(int departmentId, int callId, /// Department chat settings (config defaults when no row exists); no authorization — safe for any department-scoped caller. Task GetDepartmentSettingsAsync(int departmentId); - /// Persists department chat settings; the CALLER must verify department-admin rights first. - Task SaveDepartmentSettingsAsync(ChatDepartmentSetting settings, CancellationToken cancellationToken = default(CancellationToken)); + /// Persists department chat settings after enforcing authenticated department-admin rights. + Task SaveDepartmentSettingsAsync(int departmentId, string byUserId, ChatDepartmentSetting settings, CancellationToken cancellationToken = default(CancellationToken)); } /// @@ -183,6 +181,9 @@ public interface IChatProvisioningEventService /// public interface IChatPermissionService { + /// True only when the user is a current, active member of the authenticated department. + Task IsActiveDepartmentUserAsync(int departmentId, string userId); + /// Can the user (optionally acting for a unit) read/join this channel. Task CanAccessChannelAsync(ChatChannel channel, string userId, int? activeUnitId); @@ -195,9 +196,15 @@ public interface IChatPermissionService /// True when the unit belongs to the department AND the user actively crews it (active unit role). Task CanSendAsUnitAsync(string userId, int unitId, int departmentId); + /// True when the user has dispatch access in this department and may see its shared operational channels. + Task CanAccessDepartmentOperationalChannelsAsync(int departmentId, string userId); + /// True when the user holds an active incident-command role (or is the current IC) on the call. Task CanSendAsIcAsync(string userId, int callId, int departmentId); + /// True when the active user/unit is currently assigned or dispatched to this same-department incident. Department-wide dispatch visibility is deliberately excluded. + Task CanAccessIncidentAsync(int departmentId, int callId, string userId, int? activeUnitId); + /// /// Resolves the full user audience of a channel (for push notifications and urgent-ack provisioning). /// Unit participants expand to their active crew. Excludes removed/banned members. @@ -209,6 +216,9 @@ public interface IChatPermissionService /// Drops cached permission evaluations for a channel (membership/roles changed) and bumps the channel-list cache version. Task InvalidateChannelCacheAsync(string chatChannelId); + + /// Current distributed authorization epoch used to isolate realtime channel groups after access changes. + Task GetChannelAccessVersionAsync(string chatChannelId); } /// @@ -245,42 +255,42 @@ public class ChatModerationContext /// /// Moderation: user flags, moderator actions (delete/mute/ban/lock), the immutable moderation audit - /// trail (mirrored to the department AuditLog) and records-request exports. Permission checks - /// (CanModerateChannelAsync) are the CALLER's responsibility — controllers gate, this executes. + /// trail (mirrored to the department AuditLog) and records-request exports. Mutations accept the + /// authenticated department/user separately and re-evaluate tenant and moderator authority internally. /// public interface IChatModerationService { - /// Flags a message for review; dedupes an existing open flag by the same user. The CALLER must verify the user can access the channel. - Task FlagMessageAsync(string chatMessageId, string flaggedByUserId, ChatFlagReason reason, string note, CancellationToken cancellationToken = default(CancellationToken)); + /// Flags a message for review; enforces department scope and current channel access, then dedupes an existing open flag by the same user. + Task FlagMessageAsync(int departmentId, string chatMessageId, string flaggedByUserId, ChatFlagReason reason, string note, CancellationToken cancellationToken = default(CancellationToken)); - /// Flag queue for moderators; the CALLER must verify department-moderator rights first. - Task> GetFlagsAsync(int departmentId, ChatFlagStatus status, int page, int pageSize); + /// Flag queue for current department administrators. + Task> GetFlagsAsync(int departmentId, string byUserId, ChatFlagStatus status, int page, int pageSize); - /// Resolves a flag; departmentId must match the flag's department (cross-department ids are rejected) and only Open flags transition. The CALLER must verify moderator rights. + /// Resolves a flag for a current department administrator; cross-department ids are rejected and only Open flags transition. Task ResolveFlagAsync(string chatMessageFlagId, int departmentId, string byUserId, ChatFlagStatus resolution, string resolutionNote, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null); - /// Moderator tombstone-delete; wraps IChatMessageService.DeleteMessageAsync with audit. The CALLER must verify moderator rights. - Task ModeratorDeleteMessageAsync(string chatMessageId, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null); + /// Moderator tombstone-delete; enforces department scope and current channel moderator rights before audit. + Task ModeratorDeleteMessageAsync(int departmentId, string chatMessageId, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null); - /// Mute/unmute a participant; the CALLER must verify moderator rights first. - Task SetUserMutedAsync(string chatChannelId, string targetUserId, DateTime? mutedUntil, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null); + /// Mute/unmute a participant; enforces department scope and current channel moderator rights. + Task SetUserMutedAsync(int departmentId, string chatChannelId, string targetUserId, DateTime? mutedUntil, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null); - /// Ban/unban a participant; the CALLER must verify moderator rights first. - Task SetUserBannedAsync(string chatChannelId, string targetUserId, bool banned, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null); + /// Ban/unban a participant; enforces department scope and current channel moderator rights. + Task SetUserBannedAsync(int departmentId, string chatChannelId, string targetUserId, bool banned, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null); - /// Lock/unlock a channel; the CALLER must verify moderator rights first. - Task SetChannelLockedAsync(string chatChannelId, bool locked, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null); + /// Lock/unlock a channel; enforces department scope and current channel moderator rights. + Task SetChannelLockedAsync(int departmentId, string chatChannelId, bool locked, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null); - /// Moderation audit trail; the CALLER must verify moderator rights first. - Task> GetModerationActionsAsync(int departmentId, string chatChannelId, int page, int pageSize); + /// Moderation audit trail for current department administrators. + Task> GetModerationActionsAsync(int departmentId, string byUserId, string chatChannelId, int page, int pageSize); - /// Queues a transcript export; the CALLER must verify moderator rights first. + /// Queues a transcript export after enforcing current department-admin and channel scope. Task RequestExportAsync(int departmentId, string byUserId, string chatChannelId, DateTime? startDate, DateTime? endDate, ChatExportFormat format, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null); - /// Export list without result blobs; the CALLER must verify moderator rights first. - Task> GetExportsAsync(int departmentId); + /// Export list without result blobs for current department administrators. + Task> GetExportsAsync(int departmentId, string byUserId); - /// Full export row including result data; audits the download. The CALLER must verify moderator rights first. + /// Full export row including result data; enforces current department-admin rights and audits the download. Task GetExportForDownloadAsync(string chatExportId, int departmentId, string byUserId, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null); } @@ -349,17 +359,17 @@ public class ChatMessageSendRequest /// /// Message pipeline: validation, sequence allocation, mentions, urgent acks, edits/deletes with audit /// history, reactions, pins, read pointers, paging/delta-sync, search. Publishes ChatEventRaised - /// envelopes for realtime fan-out. SendMessageAsync enforces posting permissions internally; every - /// other method requires the CALLER to authorize via IChatPermissionService first. + /// envelopes for realtime fan-out. Every mutation takes the authenticated department and user identity + /// separately from client DTOs and re-evaluates tenant scope and channel permissions internally. /// public interface IChatMessageService { /// /// Sends a message as the authenticated user. Enforces CanPostAsync (access, mute/ban, lock) and - /// AsUnitId/AsIncidentCommander identity checks internally; MUST be - /// the authenticated user, supplied by the caller — never client input. + /// AsUnitId/AsIncidentCommander identity checks internally; and + /// MUST come from the authenticated principal — never client input. /// - Task SendMessageAsync(string senderUserId, ChatMessageSendRequest request, CancellationToken cancellationToken = default(CancellationToken)); + Task SendMessageAsync(int departmentId, string senderUserId, ChatMessageSendRequest request, CancellationToken cancellationToken = default(CancellationToken)); /// /// Internal bot send (chatbot pipeline): skips user permission checks, records no SenderUserId, @@ -379,17 +389,17 @@ public interface IChatMessageService /// Thread page; the CALLER must verify channel access first. Task> GetThreadPageAsync(string threadRootMessageId, long? beforeSeq, int limit); - /// Sender edit (enforced inside: only the original sender); prior body preserved in ChatMessageEdits. - Task EditMessageAsync(string chatMessageId, string editorUserId, string newBody, CancellationToken cancellationToken = default(CancellationToken)); + /// Sender edit; enforces department scope, channel access and original-sender identity internally. + Task EditMessageAsync(int departmentId, string chatMessageId, string editorUserId, string newBody, CancellationToken cancellationToken = default(CancellationToken)); - /// Tombstone delete; sender self-delete or moderator (asModerator) enforced inside — asModerator must only be set after the caller verified CanModerateChannelAsync. Body preserved in ChatMessageEdits until retention purge. - Task DeleteMessageAsync(string chatMessageId, string byUserId, bool asModerator, string reason, CancellationToken cancellationToken = default(CancellationToken)); + /// Tombstone delete; enforces department scope and channel access. A requested moderator delete is independently verified against current channel permissions. + Task DeleteMessageAsync(int departmentId, string chatMessageId, string byUserId, bool asModerator, string reason, CancellationToken cancellationToken = default(CancellationToken)); - /// Adds a reaction; banned/muted participants are silently skipped. The CALLER must verify channel access first. - Task AddReactionAsync(string chatMessageId, string userId, int? unitId, string emoji, CancellationToken cancellationToken = default(CancellationToken)); + /// Adds a reaction; enforces department scope, current channel access, unit authority and mute/ban state internally. + Task AddReactionAsync(int departmentId, string chatMessageId, string userId, int? unitId, string emoji, CancellationToken cancellationToken = default(CancellationToken)); - /// Removes a reaction; the CALLER must verify channel access first. - Task RemoveReactionAsync(string chatMessageId, string userId, int? unitId, string emoji, CancellationToken cancellationToken = default(CancellationToken)); + /// Removes a reaction; enforces department scope, current channel access and unit authority internally. + Task RemoveReactionAsync(int departmentId, string chatMessageId, string userId, int? unitId, string emoji, CancellationToken cancellationToken = default(CancellationToken)); /// Reaction rows for rendering; the CALLER must verify channel access first. Task> GetReactionsForMessagesAsync(List chatMessageIds); @@ -397,14 +407,14 @@ public interface IChatMessageService /// Attachment metadata for rendering; the CALLER must verify channel access first. Task> GetAttachmentMetadataForMessagesAsync(List chatMessageIds); - /// Pin/unpin; the CALLER must verify moderator rights first. - Task SetMessagePinnedAsync(string chatMessageId, string byUserId, bool pinned, CancellationToken cancellationToken = default(CancellationToken)); + /// Pin/unpin; enforces department scope and moderator rights internally. + Task SetMessagePinnedAsync(int departmentId, string chatMessageId, string byUserId, bool pinned, CancellationToken cancellationToken = default(CancellationToken)); /// Pinned messages; the CALLER must verify channel access first. Task> GetPinnedMessagesAsync(string chatChannelId); - /// Acknowledges an urgent message for the user; returns rows stamped (0 = nothing pending). - Task AcknowledgeMessageAsync(string chatMessageId, string userId, CancellationToken cancellationToken = default(CancellationToken)); + /// Acknowledges an urgent message; enforces department scope and current channel access before stamping the caller's row. + Task AcknowledgeMessageAsync(int departmentId, string chatMessageId, string userId, CancellationToken cancellationToken = default(CancellationToken)); /// Ack rows for a message; the CALLER must verify channel access first. Task> GetAcksForMessageAsync(string chatMessageId); diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs index 957313027..444b6a014 100644 --- a/Core/Resgrid.Services/ChatChannelService.cs +++ b/Core/Resgrid.Services/ChatChannelService.cs @@ -22,6 +22,7 @@ namespace Resgrid.Services public class ChatChannelService : IChatChannelService { private static readonly TimeSpan ChannelListCacheLength = TimeSpan.FromSeconds(45); + private const int MaxDerivedGroupNameLength = 100; /// How long a completed incident-channel backfill suppresses the next sweep for that command. private static readonly TimeSpan IncidentBackfillCacheLength = TimeSpan.FromMinutes(30); @@ -87,6 +88,10 @@ public async Task> GetChannelsByIdsAsync(IEnumerable c public async Task> GetChannelsForUserAsync(int departmentId, string userId, int? activeUnitId, bool includeArchived = false) { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId) || + !await IsActiveDepartmentUserAsync(departmentId, userId)) + return new List(); + async Task> getChannels() { var results = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -99,11 +104,12 @@ async Task> getChannels() // implicit-audience pass further down. var allChannels = await _chatChannelRepository.GetAllByDepartmentIdAsync(departmentId, includeArchived); - // Department admins get every group's default channel; everyone else gets only the group - // they belong to. Existing channels come from the bulk load above — only groups with no - // channel yet hit the provisioning path, and a failure there is contained per group so one - // bad group can never blank the admin's whole channel list. - if (await _chatPermissionService.IsDepartmentAdminAsync(departmentId, userId)) + // Department admins and dispatchers get every group's default channel; everyone else gets + // only the group they belong to. Existing channels come from the bulk load above — only + // groups with no channel yet hit provisioning, and one bad group cannot blank the list. + var canSeeAllOperationalChannels = await _chatPermissionService.IsDepartmentAdminAsync(departmentId, userId) || + await _chatPermissionService.CanAccessDepartmentOperationalChannelsAsync(departmentId, userId); + if (canSeeAllOperationalChannels) { var allGroups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(departmentId); if (allGroups != null && allGroups.Count > 0) @@ -160,7 +166,13 @@ async Task> getChannels() var channels = await _chatChannelRepository.GetByIdsAsync(membershipIds); if (channels != null) foreach (var channel in channels) - results[channel.ChatChannelId] = channel; + { + // Incident/default membership rows are only read-state. They can outlive the + // assignment that originally granted access, so every row must be re-evaluated. + if (channel.DepartmentId == departmentId && + await _chatPermissionService.CanAccessChannelAsync(channel, userId, activeUnitId)) + results[channel.ChatChannelId] = channel; + } } // Channels where the active unit is the participant (Dispatch/IC ↔ unit DMs, units @@ -194,7 +206,11 @@ async Task> getChannels() var channels = await _chatChannelRepository.GetByIdsAsync(unitChannelIds); if (channels != null) foreach (var channel in channels) - results[channel.ChatChannelId] = channel; + { + if (channel.DepartmentId == departmentId && + await _chatPermissionService.CanAccessChannelAsync(channel, userId, activeUnitId)) + results[channel.ChatChannelId] = channel; + } } } @@ -221,7 +237,7 @@ async Task> getChannels() } return results.Values - .Where(c => includeArchived || !c.IsArchived) + .Where(c => c.DepartmentId == departmentId && (includeArchived || !c.IsArchived)) .OrderByDescending(c => c.LastMessageOn ?? c.CreatedOn) .ToList(); } @@ -239,20 +255,18 @@ async Task> getChannels() public async Task GetOrCreateDirectMessageChannelAsync(int departmentId, string creatorUserId, string targetUserId, int? targetUnitId, CancellationToken cancellationToken = default(CancellationToken)) { - if (string.IsNullOrWhiteSpace(targetUserId) && !targetUnitId.HasValue) + var hasTargetUser = !string.IsNullOrWhiteSpace(targetUserId); + if (departmentId <= 0 || string.IsNullOrWhiteSpace(creatorUserId) || hasTargetUser == targetUnitId.HasValue) return null; + if (!await IsActiveDepartmentUserAsync(departmentId, creatorUserId)) + throw new UnauthorizedAccessException("The creator does not belong to this department."); + // A DM with yourself would put the same user in the member list twice and violate // the unique (ChatChannelId, UserId) member index; the clients never offer it. if (!targetUnitId.HasValue && string.Equals(creatorUserId, targetUserId, StringComparison.OrdinalIgnoreCase)) return null; - var dmKey = BuildDmKey(creatorUserId, targetUserId, targetUnitId); - - var existing = await _chatChannelRepository.GetByDmKeyAsync(departmentId, dmKey); - if (existing != null) - return existing; - Unit targetUnit = null; if (targetUnitId.HasValue) { @@ -260,11 +274,17 @@ async Task> getChannels() if (targetUnit == null || targetUnit.DepartmentId != departmentId) throw new UnauthorizedAccessException("The target unit does not belong to this department."); } - else if (!await _departmentsService.IsUserInDepartmentAsync(departmentId, targetUserId)) + else if (!await IsActiveDepartmentUserAsync(departmentId, targetUserId)) { throw new UnauthorizedAccessException("The target user does not belong to this department."); } + var dmKey = BuildDmKey(creatorUserId, targetUserId, targetUnitId); + + var existing = await _chatChannelRepository.GetByDmKeyAsync(departmentId, dmKey); + if (existing != null) + return existing.DepartmentId == departmentId ? existing : null; + var channel = new ChatChannel { ChatChannelId = Guid.NewGuid().ToString(), @@ -316,6 +336,10 @@ async Task> getChannels() public async Task CreateAdHocGroupChannelAsync(int departmentId, string creatorUserId, string name, List memberUserIds, CancellationToken cancellationToken = default(CancellationToken)) { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(creatorUserId) || + !await IsActiveDepartmentUserAsync(departmentId, creatorUserId)) + throw new UnauthorizedAccessException("The creator does not belong to this department."); + // Validate all member memberships before any write, so an invalid member never leaves an // orphaned channel or partial member rows to roll back. var validatedMemberIds = memberUserIds == null @@ -327,6 +351,15 @@ async Task> getChannels() if (validatedMemberIds.Any(id => !membersInDepartment.Contains(id))) throw new UnauthorizedAccessException("Every member must belong to this department."); + foreach (var memberId in validatedMemberIds) + { + if (await _departmentsService.IsUserDisabledAsync(memberId, departmentId)) + throw new UnauthorizedAccessException("Every member must be active in this department."); + } + + if (string.IsNullOrWhiteSpace(name)) + name = await BuildAdHocGroupNameAsync(validatedMemberIds); + var channel = new ChatChannel { ChatChannelId = Guid.NewGuid().ToString(), @@ -352,8 +385,56 @@ async Task> getChannels() return channel; } + private async Task BuildAdHocGroupNameAsync(List memberUserIds) + { + var names = new List(); + if (memberUserIds != null && memberUserIds.Count > 0) + { + var profiles = await _userProfileService.GetSelectedUserProfilesAsync(memberUserIds); + foreach (var profile in profiles ?? new List()) + { + var displayName = profile?.FullName?.AsFirstNameLastName; + if (!string.IsNullOrWhiteSpace(displayName)) + names.Add(displayName); + } + } + + if (names.Count == 0) + return "New group"; + + names.Sort(StringComparer.OrdinalIgnoreCase); + var joined = string.Join(", ", names); + if (joined.Length <= MaxDerivedGroupNameLength) + return joined; + + var kept = new List(); + var length = 0; + foreach (var displayName in names) + { + var addition = (kept.Count == 0 ? 0 : 2) + displayName.Length; + if (length + addition + 6 > MaxDerivedGroupNameLength) + break; + + kept.Add(displayName); + length += addition; + } + + if (kept.Count == 0) + kept.Add(names[0].Length > MaxDerivedGroupNameLength - 6 + ? names[0].Substring(0, MaxDerivedGroupNameLength - 6) + : names[0]); + + var remaining = names.Count - kept.Count; + return remaining > 0 ? $"{string.Join(", ", kept)} +{remaining}" : string.Join(", ", kept); + } + public async Task CreateCustomChannelAsync(int departmentId, string creatorUserId, string name, string topic, List accessRules, CancellationToken cancellationToken = default(CancellationToken)) { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(creatorUserId) || + !await IsActiveDepartmentUserAsync(departmentId, creatorUserId) || + !await _chatPermissionService.IsDepartmentAdminAsync(departmentId, creatorUserId)) + throw new UnauthorizedAccessException("Only an administrator in this department can create a custom channel."); + var channel = new ChatChannel { ChatChannelId = Guid.NewGuid().ToString(), @@ -389,10 +470,11 @@ async Task> getChannels() return channel; } - public async Task UpdateChannelAsync(string chatChannelId, string name, string topic, string byUserId, CancellationToken cancellationToken = default(CancellationToken)) + public async Task UpdateChannelAsync(int departmentId, string chatChannelId, string name, string topic, string byUserId, CancellationToken cancellationToken = default(CancellationToken)) { var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); - if (channel == null) + if (channel == null || channel.DepartmentId != departmentId || + !await _chatPermissionService.CanModerateChannelAsync(channel, byUserId)) return null; // Targeted update: a full-row write here would rewind LastMessageSeq/LastMessageOn over the @@ -409,10 +491,11 @@ async Task> getChannels() return channel; } - public async Task SetChannelArchivedAsync(string chatChannelId, bool archived, string byUserId, CancellationToken cancellationToken = default(CancellationToken)) + public async Task SetChannelArchivedAsync(int departmentId, string chatChannelId, bool archived, string byUserId, CancellationToken cancellationToken = default(CancellationToken)) { var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); - if (channel == null) + if (channel == null || channel.DepartmentId != departmentId || + !await _chatPermissionService.CanModerateChannelAsync(channel, byUserId)) return false; var archivedOn = archived ? DateTime.UtcNow : (DateTime?)null; @@ -431,8 +514,12 @@ async Task> getChannels() public async Task> GetMembersAsync(string chatChannelId) { + var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); + if (channel == null) + return new List(); + var members = await _chatChannelMemberRepository.GetByChannelIdAsync(chatChannelId); - return members?.ToList() ?? new List(); + return members?.Where(m => m.DepartmentId == channel.DepartmentId).ToList() ?? new List(); } public async Task> GetActiveMembershipsForUserAsync(int departmentId, string userId) @@ -466,18 +553,25 @@ public async Task GetUnitMembershipAsync(string chatChannelId return await _chatChannelMemberRepository.GetUnitMemberAsync(chatChannelId, unitId); } - public async Task> AddMembersAsync(string chatChannelId, List userIds, string addedByUserId, CancellationToken cancellationToken = default(CancellationToken)) + public async Task> AddMembersAsync(int departmentId, string chatChannelId, List userIds, string addedByUserId, CancellationToken cancellationToken = default(CancellationToken)) { var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); - if (channel == null) + if (channel == null || channel.DepartmentId != departmentId) return new List(); if (channel.ChannelType == (int)ChatChannelType.DirectMessage) throw new InvalidOperationException("Direct message channels have a fixed membership."); - if (channel.ChannelType == (int)ChatChannelType.CustomLocked && - !await _chatPermissionService.CanModerateChannelAsync(channel, addedByUserId)) - throw new UnauthorizedAccessException("Only channel moderators can add members to this channel."); + if (channel.ChannelType != (int)ChatChannelType.AdHocGroup && channel.ChannelType != (int)ChatChannelType.CustomLocked) + throw new InvalidOperationException("Members cannot be added to this channel type."); + + var canModerate = await _chatPermissionService.CanModerateChannelAsync(channel, addedByUserId); + var actorMember = await _chatChannelMemberRepository.GetUserMemberAsync(chatChannelId, addedByUserId); + var isActiveMember = actorMember != null && actorMember.DepartmentId == departmentId && !actorMember.RemovedOn.HasValue && + await IsActiveDepartmentUserAsync(departmentId, addedByUserId); + + if ((channel.ChannelType == (int)ChatChannelType.CustomLocked && !canModerate) || (!isActiveMember && !canModerate)) + throw new UnauthorizedAccessException("The actor cannot add members to this channel."); var added = new List(); @@ -485,7 +579,7 @@ public async Task GetUnitMembershipAsync(string chatChannelId { foreach (var userId in userIds.Where(u => !string.IsNullOrWhiteSpace(u)).Distinct()) { - if (!await _departmentsService.IsUserInDepartmentAsync(channel.DepartmentId, userId)) + if (!await IsActiveDepartmentUserAsync(channel.DepartmentId, userId)) throw new UnauthorizedAccessException("Every member must belong to this department."); var existing = await _chatChannelMemberRepository.GetUserMemberAsync(chatChannelId, userId); @@ -517,26 +611,34 @@ public async Task GetUnitMembershipAsync(string chatChannelId return added; } - public async Task RemoveMemberAsync(string chatChannelId, string userId, string removedByUserId, CancellationToken cancellationToken = default(CancellationToken)) + public async Task RemoveMemberAsync(int departmentId, string chatChannelId, string userId, string removedByUserId, CancellationToken cancellationToken = default(CancellationToken)) { + var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); + if (channel == null || channel.DepartmentId != departmentId || + !await IsActiveDepartmentUserAsync(departmentId, removedByUserId)) + return false; + + if (!string.Equals(userId, removedByUserId, StringComparison.OrdinalIgnoreCase) && + !await _chatPermissionService.CanModerateChannelAsync(channel, removedByUserId)) + return false; + var member = await _chatChannelMemberRepository.GetUserMemberAsync(chatChannelId, userId); - if (member == null || member.RemovedOn.HasValue) + if (member == null || member.DepartmentId != departmentId || member.RemovedOn.HasValue) return false; await _chatChannelMemberRepository.SetMemberActiveAsync(member.ChatChannelMemberId, false, cancellationToken); await _chatPermissionService.InvalidateChannelCacheAsync(chatChannelId); - var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); - if (channel != null) - PublishChannelEvent(channel, ChatEventKinds.ChannelUpdated); + PublishChannelEvent(channel, ChatEventKinds.ChannelUpdated); return true; } - public async Task ReplaceAccessRulesAsync(string chatChannelId, List accessRules, string byUserId, CancellationToken cancellationToken = default(CancellationToken)) + public async Task ReplaceAccessRulesAsync(int departmentId, string chatChannelId, List accessRules, string byUserId, CancellationToken cancellationToken = default(CancellationToken)) { var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); - if (channel == null || channel.ChannelType != (int)ChatChannelType.CustomLocked) + if (channel == null || channel.DepartmentId != departmentId || channel.ChannelType != (int)ChatChannelType.CustomLocked || + !await _chatPermissionService.CanModerateChannelAsync(channel, byUserId)) return false; // Open a shared connection/transaction so the delete and re-inserts commit atomically. @@ -577,7 +679,8 @@ public async Task GetUnitMembershipAsync(string chatChannelId public async Task EnsureMemberStateAsync(string chatChannelId, int departmentId, string userId, int? unitId, CancellationToken cancellationToken = default(CancellationToken)) { var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); - if (channel == null) + if (channel == null || channel.DepartmentId != departmentId || string.IsNullOrWhiteSpace(userId) || + !await _chatPermissionService.CanAccessChannelAsync(channel, userId, unitId)) return null; // Invite-only channel types never self-grant membership: an existing row is reactivated, @@ -605,6 +708,8 @@ public async Task GetUnitMembershipAsync(string chatChannelId throw new UnauthorizedAccessException("Membership in this channel is by invitation only."); var unit = await _unitsService.GetUnitByIdAsync(unitId.Value); + if (unit == null || unit.DepartmentId != channel.DepartmentId) + throw new UnauthorizedAccessException("The unit does not belong to this channel's department."); return await _chatChannelMemberRepository.InsertAsync(new ChatChannelMember { @@ -955,10 +1060,18 @@ public async Task EnsureIncidentCommanderLineAsync(int departmentId // EnsureIncidentChannelsAsync, a closed command provisions nothing, and reopening a reused // line here would lift the archive freeze the close put on it. var command = await _incidentCommandService.GetCommandForCallAsync(departmentId, callId); - if (command == null || command.Status != (int)IncidentCommandStatus.Active || + if (command == null || command.DepartmentId != departmentId || command.CallId != callId || + command.Status != (int)IncidentCommandStatus.Active || string.IsNullOrWhiteSpace(command.CurrentCommanderUserId)) return null; + if (!await _chatPermissionService.CanAccessIncidentAsync(departmentId, callId, requesterUserId, requesterUnitId)) + throw new UnauthorizedAccessException("The requester is not assigned to this incident."); + + if (requesterUnitId.HasValue && + !await _chatPermissionService.CanSendAsUnitAsync(requesterUserId, requesterUnitId.Value, departmentId)) + throw new UnauthorizedAccessException("The requester cannot act as this unit."); + Unit requesterUnit = null; if (requesterUnitId.HasValue) { @@ -1115,9 +1228,15 @@ public async Task EnsureIncidentCommanderLineAsync(int departmentId public async Task EnsureChatbotChannelAsync(int departmentId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId) || + !await IsActiveDepartmentUserAsync(departmentId, userId)) + return null; + var existing = await _chatChannelRepository.GetChatbotChannelAsync(departmentId, userId); if (existing != null) - return existing; + return existing.DepartmentId == departmentId && string.Equals(existing.OwnerUserId, userId, StringComparison.OrdinalIgnoreCase) + ? existing + : null; var channel = await InsertProvisionedChannelAsync(new ChatChannel { @@ -1150,6 +1269,13 @@ await _chatChannelMemberRepository.InsertAsync(new ChatChannelMember return channel; } + private async Task IsActiveDepartmentUserAsync(int departmentId, string userId) + { + return departmentId > 0 && !string.IsNullOrWhiteSpace(userId) && + await _departmentsService.IsUserInDepartmentAsync(departmentId, userId) && + !await _departmentsService.IsUserDisabledAsync(userId, departmentId); + } + public async Task SetIncidentChannelsArchivedAsync(int callId, bool archived, CancellationToken cancellationToken = default(CancellationToken)) { var affected = await _chatChannelRepository.SetArchivedByCallIdAsync(callId, archived, archived ? DateTime.UtcNow : (DateTime?)null); @@ -1209,8 +1335,12 @@ public async Task GetDepartmentSettingsAsync(int departme }; } - public async Task SaveDepartmentSettingsAsync(ChatDepartmentSetting settings, CancellationToken cancellationToken = default(CancellationToken)) + public async Task SaveDepartmentSettingsAsync(int departmentId, string byUserId, ChatDepartmentSetting settings, CancellationToken cancellationToken = default(CancellationToken)) { + if (settings == null || settings.DepartmentId != departmentId || + !await _chatPermissionService.IsDepartmentAdminAsync(departmentId, byUserId)) + return null; + var existing = await _chatDepartmentSettingRepository.GetByDepartmentIdAsync(settings.DepartmentId); if (existing == null) { diff --git a/Core/Resgrid.Services/ChatMessageService.cs b/Core/Resgrid.Services/ChatMessageService.cs index 6c4cbd7e5..4808dd48f 100644 --- a/Core/Resgrid.Services/ChatMessageService.cs +++ b/Core/Resgrid.Services/ChatMessageService.cs @@ -61,13 +61,13 @@ public ChatMessageService(IChatChannelRepository chatChannelRepository, IChatMes _eventAggregator = eventAggregator; } - public async Task SendMessageAsync(string senderUserId, ChatMessageSendRequest request, CancellationToken cancellationToken = default(CancellationToken)) + public async Task SendMessageAsync(int departmentId, string senderUserId, ChatMessageSendRequest request, CancellationToken cancellationToken = default(CancellationToken)) { - if (request == null || string.IsNullOrWhiteSpace(request.ChatChannelId) || string.IsNullOrWhiteSpace(senderUserId)) + if (departmentId <= 0 || request == null || string.IsNullOrWhiteSpace(request.ChatChannelId) || string.IsNullOrWhiteSpace(senderUserId)) return null; var channel = await _chatChannelRepository.GetByIdAsync(request.ChatChannelId); - if (channel == null || channel.DepartmentId != request.DepartmentId) + if (channel == null || channel.DepartmentId != departmentId) return null; // Idempotent resend from the mobile offline outbox. @@ -262,31 +262,42 @@ public async Task> GetThreadPageAsync(string threadRootMessage } /// - /// 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. + /// Resolves a message's persisted channel inside the authenticated tenant and re-evaluates the + /// actor's current access. Message/channel disagreement fails closed. Moderator mode never trusts + /// a caller-provided role flag: current moderator authority is loaded from the permission service. /// - private async Task IsChannelFrozenAsync(string chatChannelId) + private async Task GetAuthorizedMessageChannelAsync(ChatMessage message, int departmentId, + string userId, int? unitId, bool requireModerator = false) { - if (string.IsNullOrWhiteSpace(chatChannelId)) - return true; + if (message == null || departmentId <= 0 || message.DepartmentId != departmentId || + string.IsNullOrWhiteSpace(message.ChatChannelId) || string.IsNullOrWhiteSpace(userId)) + return null; + + var channel = await _chatChannelRepository.GetByIdAsync(message.ChatChannelId); + if (channel == null || channel.DepartmentId != departmentId) + return null; + + var authorized = requireModerator + ? await _chatPermissionService.CanModerateChannelAsync(channel, userId) + : await _chatPermissionService.CanAccessChannelAsync(channel, userId, unitId); - var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); - return channel == null || channel.IsArchived; + return authorized ? channel : null; } - public async Task EditMessageAsync(string chatMessageId, string editorUserId, string newBody, CancellationToken cancellationToken = default(CancellationToken)) + public async Task EditMessageAsync(int departmentId, string chatMessageId, string editorUserId, string newBody, CancellationToken cancellationToken = default(CancellationToken)) { var message = await _chatMessageRepository.GetByIdAsync(chatMessageId); - if (message == null || message.DeletedOn.HasValue) + if (message == null || message.DeletedOn.HasValue || message.DepartmentId != departmentId) + return null; + + var channel = await GetAuthorizedMessageChannelAsync(message, departmentId, editorUserId, null); + if (channel == null) 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)) + if (channel.IsArchived) return null; if (!string.Equals(message.SenderUserId, editorUserId, StringComparison.OrdinalIgnoreCase)) @@ -306,16 +317,15 @@ private async Task IsChannelFrozenAsync(string chatChannelId) message.Body = newBody; message.EditedOn = editedOn; - var channel = await _chatChannelRepository.GetByIdAsync(message.ChatChannelId); PublishEvent(channel, ChatEventKinds.MessageEdited, BuildMessageDto(message)); return message; } - public async Task DeleteMessageAsync(string chatMessageId, string byUserId, bool asModerator, string reason, CancellationToken cancellationToken = default(CancellationToken)) + public async Task DeleteMessageAsync(int departmentId, string chatMessageId, string byUserId, bool asModerator, string reason, CancellationToken cancellationToken = default(CancellationToken)) { var message = await _chatMessageRepository.GetByIdAsync(chatMessageId); - if (message == null || message.DeletedOn.HasValue) + if (message == null || message.DeletedOn.HasValue || message.DepartmentId != departmentId) return false; var isSender = string.Equals(message.SenderUserId, byUserId, StringComparison.OrdinalIgnoreCase); @@ -323,10 +333,13 @@ private async Task IsChannelFrozenAsync(string chatChannelId) return false; var isModeratorDelete = asModerator && !isSender; + var channel = await GetAuthorizedMessageChannelAsync(message, departmentId, byUserId, null, isModeratorDelete); + if (channel == null) + return false; // 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)) + if (!isModeratorDelete && channel.IsArchived) return false; await SaveEditHistoryAsync(message, isModeratorDelete ? ChatMessageEditType.ModeratorDelete : ChatMessageEditType.SenderDelete, byUserId, cancellationToken); @@ -341,7 +354,6 @@ private async Task IsChannelFrozenAsync(string chatChannelId) message.DeletedByUserId = byUserId; message.IsModerated = isModeratorDelete; - var channel = await _chatChannelRepository.GetByIdAsync(message.ChatChannelId); PublishEvent(channel, ChatEventKinds.MessageDeleted, new { message.ChatMessageId, @@ -355,16 +367,18 @@ private async Task IsChannelFrozenAsync(string chatChannelId) return true; } - public async Task AddReactionAsync(string chatMessageId, string userId, int? unitId, string emoji, CancellationToken cancellationToken = default(CancellationToken)) + public async Task AddReactionAsync(int departmentId, string chatMessageId, string userId, int? unitId, string emoji, CancellationToken cancellationToken = default(CancellationToken)) { if (string.IsNullOrWhiteSpace(emoji) || emoji.Length > 64) return false; var message = await _chatMessageRepository.GetByIdAsync(chatMessageId); - if (message == null || message.DeletedOn.HasValue) + if (message == null || message.DeletedOn.HasValue || message.DepartmentId != departmentId) return false; - if (await IsChannelFrozenAsync(message.ChatChannelId)) + var channel = await GetAuthorizedMessageChannelAsync(message, departmentId, userId, unitId); + if (channel == null || channel.IsArchived || + (unitId.HasValue && !await _chatPermissionService.CanSendAsUnitAsync(userId, unitId.Value, departmentId))) return false; // Banned or currently-muted participants can't react; silently skip. @@ -408,19 +422,20 @@ await _chatMessageReactionRepository.InsertAsync(new ChatMessageReaction return true; } - var channel = await _chatChannelRepository.GetByIdAsync(message.ChatChannelId); PublishEvent(channel, ChatEventKinds.ReactionUpdated, new { message.ChatMessageId, message.ChatChannelId, Emoji = emoji, UserId = userId, UnitId = unitId, Added = true }); return true; } - public async Task RemoveReactionAsync(string chatMessageId, string userId, int? unitId, string emoji, CancellationToken cancellationToken = default(CancellationToken)) + public async Task RemoveReactionAsync(int departmentId, string chatMessageId, string userId, int? unitId, string emoji, CancellationToken cancellationToken = default(CancellationToken)) { var message = await _chatMessageRepository.GetByIdAsync(chatMessageId); - if (message == null) + if (message == null || message.DepartmentId != departmentId) return false; - if (await IsChannelFrozenAsync(message.ChatChannelId)) + var channel = await GetAuthorizedMessageChannelAsync(message, departmentId, userId, unitId); + if (channel == null || channel.IsArchived || + (unitId.HasValue && !await _chatPermissionService.CanSendAsUnitAsync(userId, unitId.Value, departmentId))) return false; var participantType = unitId.HasValue ? (int)ChatParticipantType.Unit : (int)ChatParticipantType.User; @@ -428,7 +443,6 @@ await _chatMessageReactionRepository.InsertAsync(new ChatMessageReaction if (removed) { - var channel = await _chatChannelRepository.GetByIdAsync(message.ChatChannelId); PublishEvent(channel, ChatEventKinds.ReactionUpdated, new { message.ChatMessageId, message.ChatChannelId, Emoji = emoji, UserId = userId, UnitId = unitId, Added = false }); } @@ -447,10 +461,14 @@ public async Task> GetAttachmentMetadataForMessagesAsync(Li return attachments?.ToList() ?? new List(); } - public async Task SetMessagePinnedAsync(string chatMessageId, string byUserId, bool pinned, CancellationToken cancellationToken = default(CancellationToken)) + public async Task SetMessagePinnedAsync(int departmentId, string chatMessageId, string byUserId, bool pinned, CancellationToken cancellationToken = default(CancellationToken)) { var message = await _chatMessageRepository.GetByIdAsync(chatMessageId); - if (message == null || message.DeletedOn.HasValue) + if (message == null || message.DeletedOn.HasValue || message.DepartmentId != departmentId) + return false; + + var channel = await GetAuthorizedMessageChannelAsync(message, departmentId, byUserId, null, requireModerator: true); + if (channel == null) return false; var pinnedOn = pinned ? DateTime.UtcNow : (DateTime?)null; @@ -460,7 +478,6 @@ public async Task> GetAttachmentMetadataForMessagesAsync(Li message.PinnedOn = pinnedOn; message.PinnedByUserId = pinned ? byUserId : null; - var channel = await _chatChannelRepository.GetByIdAsync(message.ChatChannelId); PublishEvent(channel, ChatEventKinds.ChannelUpdated, new { message.ChatChannelId, PinnedMessageId = message.ChatMessageId, Pinned = pinned }); return true; @@ -472,19 +489,20 @@ public async Task> GetPinnedMessagesAsync(string chatChannelId return pinned?.ToList() ?? new List(); } - public async Task AcknowledgeMessageAsync(string chatMessageId, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public async Task AcknowledgeMessageAsync(int departmentId, string chatMessageId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { + var message = await _chatMessageRepository.GetByIdAsync(chatMessageId); + if (message == null || message.DepartmentId != departmentId) + return 0; + + var channel = await GetAuthorizedMessageChannelAsync(message, departmentId, userId, null); + if (channel == null) + return 0; + var stamped = await _chatMessageAckRepository.AcknowledgeAsync(chatMessageId, userId, DateTime.UtcNow); if (stamped > 0) - { - var message = await _chatMessageRepository.GetByIdAsync(chatMessageId); - if (message != null) - { - var channel = await _chatChannelRepository.GetByIdAsync(message.ChatChannelId); - PublishEvent(channel, ChatEventKinds.ReceiptUpdated, new { message.ChatMessageId, message.ChatChannelId, Type = "ack", UserId = userId }); - } - } + PublishEvent(channel, ChatEventKinds.ReceiptUpdated, new { message.ChatMessageId, message.ChatChannelId, Type = "ack", UserId = userId }); return stamped; } @@ -498,7 +516,25 @@ public async Task> GetAcksForMessageAsync(string chatMessag public async Task> GetPendingAcksForUserAsync(int departmentId, string userId) { var acks = await _chatMessageAckRepository.GetPendingByUserIdAsync(departmentId, userId); - return acks?.ToList() ?? new List(); + var candidates = acks?.Where(a => a.DepartmentId == departmentId && !string.IsNullOrWhiteSpace(a.ChatChannelId)).ToList() + ?? new List(); + if (candidates.Count == 0) + return candidates; + + var channels = await _chatChannelService.GetChannelsByIdsAsync(candidates.Select(a => a.ChatChannelId)); + var channelsById = channels + .Where(c => c.DepartmentId == departmentId) + .GroupBy(c => c.ChatChannelId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var authorizedChannelIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var channel in channelsById.Values) + { + if (await _chatPermissionService.CanAccessChannelAsync(channel, userId, null)) + authorizedChannelIds.Add(channel.ChatChannelId); + } + + return candidates.Where(a => authorizedChannelIds.Contains(a.ChatChannelId)).ToList(); } public async Task MarkReadAsync(string chatChannelId, int departmentId, string userId, int? unitId, long seq, CancellationToken cancellationToken = default(CancellationToken)) @@ -536,7 +572,8 @@ public async Task> SearchAsync(int departmentId, string userId if (!string.IsNullOrWhiteSpace(chatChannelId)) { var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); - if (channel == null || !await _chatPermissionService.CanAccessChannelAsync(channel, userId, activeUnitId)) + if (channel == null || channel.DepartmentId != departmentId || + !await _chatPermissionService.CanAccessChannelAsync(channel, userId, activeUnitId)) return new List(); channelIds = new List { chatChannelId }; diff --git a/Core/Resgrid.Services/ChatModerationService.cs b/Core/Resgrid.Services/ChatModerationService.cs index 3ceea25ea..03f2c988e 100644 --- a/Core/Resgrid.Services/ChatModerationService.cs +++ b/Core/Resgrid.Services/ChatModerationService.cs @@ -49,10 +49,15 @@ public ChatModerationService(IChatMessageFlagRepository chatMessageFlagRepositor _eventAggregator = eventAggregator; } - public async Task FlagMessageAsync(string chatMessageId, string flaggedByUserId, ChatFlagReason reason, string note, CancellationToken cancellationToken = default(CancellationToken)) + public async Task FlagMessageAsync(int departmentId, string chatMessageId, string flaggedByUserId, ChatFlagReason reason, string note, CancellationToken cancellationToken = default(CancellationToken)) { var message = await _chatMessageRepository.GetByIdAsync(chatMessageId); - if (message == null) + if (message == null || message.DepartmentId != departmentId) + return null; + + var channel = await _chatChannelRepository.GetByIdAsync(message.ChatChannelId); + if (channel == null || channel.DepartmentId != departmentId || + !await _chatPermissionService.CanAccessChannelAsync(channel, flaggedByUserId, null)) return null; // Dedupe: an open flag by the same user on the same message is returned, not duplicated. @@ -78,14 +83,20 @@ public ChatModerationService(IChatMessageFlagRepository chatMessageFlagRepositor return flag; } - public async Task> GetFlagsAsync(int departmentId, ChatFlagStatus status, int page, int pageSize) + public async Task> GetFlagsAsync(int departmentId, string byUserId, ChatFlagStatus status, int page, int pageSize) { + if (!await _chatPermissionService.IsDepartmentAdminAsync(departmentId, byUserId)) + return new List(); + var flags = await _chatMessageFlagRepository.GetByStatusAsync(departmentId, (int)status, Math.Max(page, 1), pageSize <= 0 ? 25 : Math.Min(pageSize, 100)); return flags?.ToList() ?? new List(); } public async Task ResolveFlagAsync(string chatMessageFlagId, int departmentId, string byUserId, ChatFlagStatus resolution, string resolutionNote, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null) { + if (!await _chatPermissionService.IsDepartmentAdminAsync(departmentId, byUserId)) + return null; + var flag = await _chatMessageFlagRepository.GetByIdAsync(chatMessageFlagId); if (flag == null || flag.DepartmentId != departmentId) return null; @@ -109,13 +120,14 @@ await RecordActionAsync(flag.DepartmentId, flag.ChatChannelId, flag.ChatMessageI return saved; } - public async Task ModeratorDeleteMessageAsync(string chatMessageId, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null) + public async Task ModeratorDeleteMessageAsync(int departmentId, string chatMessageId, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null) { var message = await _chatMessageRepository.GetByIdAsync(chatMessageId); - if (message == null) + if (message == null || message.DepartmentId != departmentId || + await ResolveModeratedChannelAsync(departmentId, message.ChatChannelId, byUserId) == null) return false; - var deleted = await _chatMessageService.DeleteMessageAsync(chatMessageId, byUserId, asModerator: true, reason, cancellationToken); + var deleted = await _chatMessageService.DeleteMessageAsync(departmentId, chatMessageId, byUserId, asModerator: true, reason, cancellationToken); if (!deleted) return false; @@ -125,13 +137,13 @@ await RecordActionAsync(message.DepartmentId, message.ChatChannelId, chatMessage return true; } - public async Task SetUserMutedAsync(string chatChannelId, string targetUserId, DateTime? mutedUntil, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null) + public async Task SetUserMutedAsync(int departmentId, string chatChannelId, string targetUserId, DateTime? mutedUntil, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null) { - var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); + var channel = await ResolveModeratedChannelAsync(departmentId, chatChannelId, byUserId); if (channel == null) return false; - var member = await _chatChannelService.EnsureMemberStateAsync(chatChannelId, channel.DepartmentId, targetUserId, null, cancellationToken); + var member = await _chatChannelService.EnsureMemberStateAsync(chatChannelId, departmentId, targetUserId, null, cancellationToken); if (member == null) return false; @@ -148,13 +160,13 @@ await RecordActionAsync(channel.DepartmentId, chatChannelId, null, targetUserId, return true; } - public async Task SetUserBannedAsync(string chatChannelId, string targetUserId, bool banned, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null) + public async Task SetUserBannedAsync(int departmentId, string chatChannelId, string targetUserId, bool banned, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null) { - var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); + var channel = await ResolveModeratedChannelAsync(departmentId, chatChannelId, byUserId); if (channel == null) return false; - var member = await _chatChannelService.EnsureMemberStateAsync(chatChannelId, channel.DepartmentId, targetUserId, null, cancellationToken); + var member = await _chatChannelService.EnsureMemberStateAsync(chatChannelId, departmentId, targetUserId, null, cancellationToken); if (member == null) return false; @@ -169,9 +181,9 @@ await RecordActionAsync(channel.DepartmentId, chatChannelId, null, targetUserId, return true; } - public async Task SetChannelLockedAsync(string chatChannelId, bool locked, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null) + public async Task SetChannelLockedAsync(int departmentId, string chatChannelId, bool locked, string byUserId, string reason, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null) { - var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); + var channel = await ResolveModeratedChannelAsync(departmentId, chatChannelId, byUserId); if (channel == null) return false; @@ -189,14 +201,34 @@ await RecordActionAsync(channel.DepartmentId, chatChannelId, null, null, null, return true; } - public async Task> GetModerationActionsAsync(int departmentId, string chatChannelId, int page, int pageSize) + public async Task> GetModerationActionsAsync(int departmentId, string byUserId, string chatChannelId, int page, int pageSize) { + if (!await _chatPermissionService.IsDepartmentAdminAsync(departmentId, byUserId)) + return new List(); + + if (!string.IsNullOrWhiteSpace(chatChannelId)) + { + var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); + if (channel == null || channel.DepartmentId != departmentId) + return new List(); + } + var actions = await _chatModerationActionRepository.GetByDepartmentAsync(departmentId, chatChannelId, Math.Max(page, 1), pageSize <= 0 ? 25 : Math.Min(pageSize, 100)); return actions?.ToList() ?? new List(); } public async Task RequestExportAsync(int departmentId, string byUserId, string chatChannelId, DateTime? startDate, DateTime? endDate, ChatExportFormat format, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null) { + if (!await _chatPermissionService.IsDepartmentAdminAsync(departmentId, byUserId)) + return null; + + if (!string.IsNullOrWhiteSpace(chatChannelId)) + { + var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); + if (channel == null || channel.DepartmentId != departmentId) + return null; + } + var export = await _chatExportRepository.InsertAsync(new ChatExport { ChatExportId = Guid.NewGuid().ToString(), @@ -218,14 +250,20 @@ await RecordActionAsync(departmentId, chatChannelId, null, null, null, return export; } - public async Task> GetExportsAsync(int departmentId) + public async Task> GetExportsAsync(int departmentId, string byUserId) { + if (!await _chatPermissionService.IsDepartmentAdminAsync(departmentId, byUserId)) + return new List(); + var exports = await _chatExportRepository.GetMetadataByDepartmentIdAsync(departmentId); return exports?.ToList() ?? new List(); } public async Task GetExportForDownloadAsync(string chatExportId, int departmentId, string byUserId, CancellationToken cancellationToken = default(CancellationToken), ChatModerationContext context = null) { + if (!await _chatPermissionService.IsDepartmentAdminAsync(departmentId, byUserId)) + return null; + var export = await _chatExportRepository.GetByIdAsync(chatExportId); if (export == null || export.DepartmentId != departmentId || export.Status != (int)ChatExportStatus.Complete) return null; @@ -238,6 +276,19 @@ await RecordActionAsync(departmentId, export.ChatChannelId, null, null, null, return export; } + private async Task ResolveModeratedChannelAsync(int departmentId, string chatChannelId, string byUserId) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(chatChannelId) || string.IsNullOrWhiteSpace(byUserId)) + return null; + + var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); + if (channel == null || channel.DepartmentId != departmentId || + !await _chatPermissionService.CanModerateChannelAsync(channel, byUserId)) + return null; + + return channel; + } + private async Task RecordActionAsync(int departmentId, string chatChannelId, string chatMessageId, string targetUserId, int? targetUnitId, ChatModerationActionType actionType, string byUserId, string reason, string detailsJson, AuditLogTypes auditLogType, CancellationToken cancellationToken, ChatModerationContext context = null) diff --git a/Core/Resgrid.Services/ChatPermissionService.cs b/Core/Resgrid.Services/ChatPermissionService.cs index e25050e9b..e42311442 100644 --- a/Core/Resgrid.Services/ChatPermissionService.cs +++ b/Core/Resgrid.Services/ChatPermissionService.cs @@ -56,19 +56,35 @@ public ChatPermissionService(IChatChannelMemberRepository chatChannelMemberRepos public async Task CanAccessChannelAsync(ChatChannel channel, string userId, int? activeUnitId) { - if (channel == null || string.IsNullOrWhiteSpace(userId)) + if (channel == null || string.IsNullOrWhiteSpace(userId) || + !await IsActiveDepartmentUserAsync(channel.DepartmentId, userId)) + return false; + + // A channel ban overrides implicit access (department, incident, dispatch, group/rule or + // command role). Checking only explicit-membership branches would let a banned responder + // re-enter the same channel through their incident/resource assignment. + var userMember = await _chatChannelMemberRepository.GetUserMemberAsync(channel.ChatChannelId, userId); + if (userMember != null && userMember.DepartmentId == channel.DepartmentId && userMember.IsBanned) return false; + var channelType = (ChatChannelType)channel.ChannelType; + // Operational access is derived from live dispatch, incident, lane, command and unit-role state. + // Never return a cached allow after a resource release, lane move, command transfer or dispatch + // revocation; those changes are authorization boundaries, not eventual-consistency hints. + if (IsDispatchVisibleChannel(channelType) || channelType == ChatChannelType.IncidentCommanderLine) + return await EvaluateAccessAsync(channel, userId, activeUnitId); + var cacheKey = await GetPermCacheKeyAsync(channel.ChatChannelId, "access", userId, activeUnitId); var cached = await _cacheProvider.GetStringAsync(cacheKey); - if (cached == "1") - return true; + // A cached denial is safe; a cached allow is not an authorization boundary. Membership, + // role and rule revocations may originate outside chat, so positive access is re-evaluated. if (cached == "0") return false; var result = await EvaluateAccessAsync(channel, userId, activeUnitId); - await _cacheProvider.SetStringAsync(cacheKey, result ? "1" : "0", CacheLength); + if (!result) + await _cacheProvider.SetStringAsync(cacheKey, "0", CacheLength); return result; } @@ -117,25 +133,19 @@ public async Task CanPostAsync(ChatChannel channel, string userId, int? as public async Task CanModerateChannelAsync(ChatChannel channel, string userId) { - if (channel == null || string.IsNullOrWhiteSpace(userId)) + if (channel == null || string.IsNullOrWhiteSpace(userId) || + !await IsActiveDepartmentUserAsync(channel.DepartmentId, userId)) return false; - var cacheKey = await GetPermCacheKeyAsync(channel.ChatChannelId, "mod", userId, null); - var cached = await _cacheProvider.GetStringAsync(cacheKey); - if (cached == "1") - return true; - if (cached == "0") - return false; - - var result = await EvaluateModerateAsync(channel, userId); - - await _cacheProvider.SetStringAsync(cacheKey, result ? "1" : "0", CacheLength); - - return result; + // Moderation is low-volume and role changes must take effect immediately (especially IC transfer). + return await EvaluateModerateAsync(channel, userId); } public async Task CanSendAsUnitAsync(string userId, int unitId, int departmentId) { + if (!await IsActiveDepartmentUserAsync(departmentId, userId)) + return false; + var unit = await _unitsService.GetUnitByIdAsync(unitId); if (unit == null || unit.DepartmentId != departmentId) return false; @@ -147,8 +157,11 @@ public async Task CanSendAsUnitAsync(string userId, int unitId, int depart public async Task CanSendAsIcAsync(string userId, int callId, int departmentId) { + if (!await IsActiveDepartmentUserAsync(departmentId, userId)) + return false; + var command = await _incidentCommandService.GetCommandForCallAsync(departmentId, callId); - if (command == null) + if (command == null || command.DepartmentId != departmentId || command.CallId != callId) return false; if (command.CurrentCommanderUserId == userId || command.EstablishedByUserId == userId) @@ -158,11 +171,34 @@ public async Task CanSendAsIcAsync(string userId, int callId, int departme return roles != null && roles.Any(r => r.UserId == userId && !r.RemovedOn.HasValue); } + public async Task CanAccessIncidentAsync(int departmentId, int callId, string userId, int? activeUnitId) + { + if (callId <= 0 || !await IsActiveDepartmentUserAsync(departmentId, userId)) + return false; + + var call = await _callsService.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != departmentId) + return false; + + return await IsInIncidentAudienceAsync(new ChatChannel + { + DepartmentId = departmentId, + CallId = callId, + ChannelType = (int)ChatChannelType.Incident + }, userId, activeUnitId); + } + + public async Task CanAccessDepartmentOperationalChannelsAsync(int departmentId, string userId) + { + return await IsActiveDepartmentUserAsync(departmentId, userId) && + await _dispatchAccessService.CanUseDispatchAsync(departmentId, userId); + } + public async Task> ResolveChannelAudienceUserIdsAsync(ChatChannel channel) { var userIds = new HashSet(StringComparer.OrdinalIgnoreCase); - if (channel == null) + if (channel == null || !await HasValidDepartmentScopeAsync(channel)) return userIds.ToList(); switch ((ChatChannelType)channel.ChannelType) @@ -183,7 +219,7 @@ public async Task> ResolveChannelAudienceUserIdsAsync(ChatChannel c { var groupMembers = await _departmentGroupsService.GetAllMembersForGroupAsync(channel.GroupId.Value); if (groupMembers != null) - foreach (var m in groupMembers) + foreach (var m in groupMembers.Where(m => m.DepartmentId == channel.DepartmentId)) AddIfSet(userIds, m.UserId); } break; @@ -195,9 +231,6 @@ 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: @@ -214,23 +247,20 @@ public async Task> ResolveChannelAudienceUserIdsAsync(ChatChannel c case ChatChannelType.IncidentDispatch: await AddIncidentAudienceAsync(channel, userIds); - foreach (var dispatcherId in await _dispatchAccessService.GetDispatchUserIdsAsync(channel.DepartmentId)) - AddIfSet(userIds, dispatcherId); break; case ChatChannelType.UnitDispatch: - // The unit's member row resolves to its active crew; the desk side is every dispatcher. + // The unit's member row resolves to its active crew. await AddExplicitMemberAudienceAsync(channel, userIds); - foreach (var dispatcherId in await _dispatchAccessService.GetDispatchUserIdsAsync(channel.DepartmentId)) - AddIfSet(userIds, dispatcherId); break; case ChatChannelType.IncidentCommanderLine: // Requester side is an explicit member row; the commander side is resolved live from the - // call so a command transfer moves the conversation rather than copying it. Only the + // call. The requester row is also filtered through current incident assignment so a + // release or lane removal immediately removes history and notification access. Only the // CURRENT commander — deliberately not EstablishedByUserId or the wider command staff, // which is what separates this from the IncidentCommand channel. - await AddExplicitMemberAudienceAsync(channel, userIds); + await AddIncidentCommanderLineRequesterAudienceAsync(channel, userIds); AddIfSet(userIds, await GetCurrentCommanderUserIdAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault())); break; @@ -239,7 +269,25 @@ public async Task> ResolveChannelAudienceUserIdsAsync(ChatChannel c break; } - return userIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (IsDispatchVisibleChannel((ChatChannelType)channel.ChannelType)) + { + foreach (var dispatcherId in await _dispatchAccessService.GetDispatchUserIdsAsync(channel.DepartmentId)) + AddIfSet(userIds, dispatcherId); + } + + // Audience resolution feeds push notifications and urgent acknowledgements. Never let a stale + // member/rule/assignment row send department chat content to a user outside the channel tenant. + var departmentUserIds = new HashSet( + await _departmentsService.GetMemberUserIdsInDepartmentAsync(channel.DepartmentId, userIds) ?? new HashSet(), + StringComparer.OrdinalIgnoreCase); + var activeUserIds = new List(); + foreach (var userId in userIds.Where(x => !string.IsNullOrWhiteSpace(x) && departmentUserIds.Contains(x))) + { + if (await _authorizationService.IsUserValidWithinLimitsAsync(userId, channel.DepartmentId)) + activeUserIds.Add(userId); + } + + return activeUserIds; } public async Task InvalidateChannelCacheAsync(string chatChannelId) @@ -253,23 +301,48 @@ public async Task InvalidateChannelCacheAsync(string chatChannelId) await _cacheProvider.IncrementAsync(ChannelListVersionCacheKey, VersionCacheLength); } + public async Task GetChannelAccessVersionAsync(string chatChannelId) + { + if (string.IsNullOrWhiteSpace(chatChannelId)) + return null; + + try + { + return await _cacheProvider.GetStringAsync(GetVersionKey(chatChannelId)) ?? "0"; + } + catch (Exception ex) + { + // A missing authorization epoch must stop realtime fan-out. Falling back to the old + // group during a cache outage could reconnect a user whose access was just revoked. + Resgrid.Framework.Logging.LogException(ex); + return null; + } + } + private async Task EvaluateAccessAsync(ChatChannel channel, string userId, int? activeUnitId) { - switch ((ChatChannelType)channel.ChannelType) + if (!await HasValidDepartmentScopeAsync(channel)) + return false; + + var channelType = (ChatChannelType)channel.ChannelType; + if (IsDispatchVisibleChannel(channelType) && await _dispatchAccessService.CanUseDispatchAsync(channel.DepartmentId, userId)) + return true; + + switch (channelType) { case ChatChannelType.Chatbot: return string.Equals(channel.OwnerUserId, userId, StringComparison.OrdinalIgnoreCase); case ChatChannelType.DirectMessage: case ChatChannelType.AdHocGroup: - if (await HasActiveMembershipAsync(channel.ChatChannelId, userId, null)) + if (await HasActiveMembershipAsync(channel.ChatChannelId, channel.DepartmentId, userId, null)) return true; // The unit's member row only grants access when the caller actually crews the claimed // unit — a caller-supplied activeUnitId alone must not open another unit's channels. return activeUnitId.HasValue && await CanSendAsUnitAsync(userId, activeUnitId.Value, channel.DepartmentId) - && await HasActiveMembershipAsync(channel.ChatChannelId, userId, activeUnitId); + && await HasActiveMembershipAsync(channel.ChatChannelId, channel.DepartmentId, userId, activeUnitId); case ChatChannelType.DepartmentDefault: return await _departmentsService.IsUserInDepartmentAsync(channel.DepartmentId, userId); @@ -282,66 +355,35 @@ private async Task EvaluateAccessAsync(ChatChannel channel, string userId, return false; var groupMembers = await _departmentGroupsService.GetAllMembersForGroupAsync(channel.GroupId.Value); - return groupMembers != null && groupMembers.Any(m => string.Equals(m.UserId, userId, StringComparison.OrdinalIgnoreCase)); + return groupMembers != null && groupMembers.Any(m => m.DepartmentId == channel.DepartmentId && + string.Equals(m.UserId, userId, StringComparison.OrdinalIgnoreCase)); case ChatChannelType.CustomLocked: if (await IsDepartmentAdminAsync(channel.DepartmentId, userId)) return true; - if (await HasActiveMembershipAsync(channel.ChatChannelId, userId, null)) + if (await HasActiveMembershipAsync(channel.ChatChannelId, channel.DepartmentId, userId, null)) return true; return await MatchesAccessRulesAsync(channel, userId); case ChatChannelType.Incident: - 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: - if (await IsDepartmentAdminAsync(channel.DepartmentId, userId)) - return true; - return await IsInLaneAudienceAsync(channel, userId, activeUnitId); case ChatChannelType.IncidentCommand: - 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: - if (await IsDepartmentAdminAsync(channel.DepartmentId, userId)) - return true; - 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); case ChatChannelType.UnitDispatch: { - // Same stance as IncidentDispatch: dispatch authorization, not admin standing, opens - // dispatch traffic. - if (await _dispatchAccessService.CanUseDispatchAsync(channel.DepartmentId, userId)) - return true; - // The unit side is proven against the channel's OWN unit, never the caller-supplied // activeUnitId or a leftover user member row (lazy read-pointer rows outlive access) — // crewing some other unit must not open this unit's dispatch line. @@ -360,12 +402,14 @@ private async Task EvaluateAccessAsync(ChatChannel channel, string userId, // Requester side, proven the same way DMs are — a unit's row only counts when the caller // actually crews that unit. - if (await HasActiveMembershipAsync(channel.ChatChannelId, userId, null)) + if (await HasActiveMembershipAsync(channel.ChatChannelId, channel.DepartmentId, userId, null) && + await IsInIncidentAudienceAsync(channel, userId, null)) return true; return activeUnitId.HasValue && await CanSendAsUnitAsync(userId, activeUnitId.Value, channel.DepartmentId) - && await HasActiveMembershipAsync(channel.ChatChannelId, userId, activeUnitId); + && await HasActiveMembershipAsync(channel.ChatChannelId, channel.DepartmentId, userId, activeUnitId) + && await IsInIncidentAudienceAsync(channel, userId, activeUnitId); } default: @@ -390,12 +434,15 @@ private async Task GetCurrentCommanderUserIdAsync(int departmentId, int private async Task EvaluateModerateAsync(ChatChannel channel, string userId) { + if (!await HasValidDepartmentScopeAsync(channel)) + return false; + // Department admins moderate every channel type (including DMs, for flagged-content handling). if (await IsDepartmentAdminAsync(channel.DepartmentId, userId)) return true; var member = await _chatChannelMemberRepository.GetUserMemberAsync(channel.ChatChannelId, userId); - if (member != null && member.IsModerator && !member.RemovedOn.HasValue) + if (member != null && member.DepartmentId == channel.DepartmentId && member.IsModerator && !member.RemovedOn.HasValue) return true; switch ((ChatChannelType)channel.ChannelType) @@ -405,7 +452,8 @@ private async Task EvaluateModerateAsync(ChatChannel channel, string userI return false; var groupMembers = await _departmentGroupsService.GetAllMembersForGroupAsync(channel.GroupId.Value); - return groupMembers != null && groupMembers.Any(m => string.Equals(m.UserId, userId, StringComparison.OrdinalIgnoreCase) && m.IsAdmin.GetValueOrDefault()); + return groupMembers != null && groupMembers.Any(m => m.DepartmentId == channel.DepartmentId && + string.Equals(m.UserId, userId, StringComparison.OrdinalIgnoreCase) && m.IsAdmin.GetValueOrDefault()); case ChatChannelType.Incident: case ChatChannelType.IncidentLane: @@ -441,19 +489,20 @@ private async Task EvaluateModerateAsync(ChatChannel channel, string userI return unitId; var members = await _chatChannelMemberRepository.GetByChannelIdAsync(channel.ChatChannelId); - return members?.FirstOrDefault(m => m.ParticipantType == (int)ChatParticipantType.Unit && !m.RemovedOn.HasValue && m.UnitId.HasValue)?.UnitId; + return members?.FirstOrDefault(m => m.DepartmentId == channel.DepartmentId && + m.ParticipantType == (int)ChatParticipantType.Unit && !m.RemovedOn.HasValue && m.UnitId.HasValue)?.UnitId; } - private async Task HasActiveMembershipAsync(string chatChannelId, string userId, int? activeUnitId) + private async Task HasActiveMembershipAsync(string chatChannelId, int departmentId, string userId, int? activeUnitId) { var member = await _chatChannelMemberRepository.GetUserMemberAsync(chatChannelId, userId); - if (member != null && !member.RemovedOn.HasValue && !member.IsBanned) + if (member != null && member.DepartmentId == departmentId && !member.RemovedOn.HasValue && !member.IsBanned) return true; if (activeUnitId.HasValue) { var unitMember = await _chatChannelMemberRepository.GetUnitMemberAsync(chatChannelId, activeUnitId.Value); - if (unitMember != null && !unitMember.RemovedOn.HasValue && !unitMember.IsBanned) + if (unitMember != null && unitMember.DepartmentId == departmentId && !unitMember.RemovedOn.HasValue && !unitMember.IsBanned) return true; } @@ -466,7 +515,7 @@ private async Task MatchesAccessRulesAsync(ChatChannel channel, string use if (rules == null) return false; - var ruleList = rules.ToList(); + var ruleList = rules.Where(r => r.DepartmentId == channel.DepartmentId).ToList(); if (ruleList.Count == 0) return false; @@ -483,7 +532,7 @@ private async Task MatchesAccessRulesAsync(ChatChannel channel, string use foreach (var groupRule in ruleList.Where(r => r.RuleType == (int)ChatAccessRuleType.GroupMembership && r.GroupId.HasValue)) { - var memberUserIds = await GetGroupRosterUserIdsAsync(groupRule.GroupId.Value); + var memberUserIds = await GetGroupRosterUserIdsAsync(groupRule.GroupId.Value, channel.DepartmentId); if (memberUserIds != null && memberUserIds.Any(id => string.Equals(id, userId, StringComparison.OrdinalIgnoreCase))) return true; } @@ -492,20 +541,24 @@ private async Task MatchesAccessRulesAsync(ChatChannel channel, string use } /// Group roster lookup cached briefly: access-rule evaluation walks every group rule per user, which would otherwise N+1 the group-membership table. - private async Task> GetGroupRosterUserIdsAsync(int groupId) + private async Task> GetGroupRosterUserIdsAsync(int groupId, int departmentId) { async Task getRoster() { + var group = await _departmentGroupsService.GetGroupByIdAsync(groupId, false); + if (group == null || group.DepartmentId != departmentId) + return new GroupRosterCache(); + var members = await _departmentGroupsService.GetAllMembersForGroupAsync(groupId); return new GroupRosterCache { - UserIds = members?.Where(m => !string.IsNullOrWhiteSpace(m.UserId)).Select(m => m.UserId).ToList() ?? new List() + UserIds = members?.Where(m => m.DepartmentId == departmentId && !string.IsNullOrWhiteSpace(m.UserId)).Select(m => m.UserId).ToList() ?? new List() }; } if (SystemBehaviorConfig.CacheEnabled) { - var cached = await _cacheProvider.RetrieveAsync($"chatperm:grouproster:{groupId}", getRoster, CacheLength); + var cached = await _cacheProvider.RetrieveAsync($"chatperm:grouproster:{departmentId}:{groupId}", getRoster, CacheLength); return cached?.UserIds; } @@ -530,7 +583,7 @@ private async Task IsInIncidentAudienceAsync(ChatChannel channel, string u return true; var call = await _callsService.GetCallByIdAsync(callId); - if (call != null) + if (call != null && call.DepartmentId == channel.DepartmentId) { if (call.Dispatches != null && call.Dispatches.Any(d => string.Equals(d.UserId, userId, StringComparison.OrdinalIgnoreCase))) return true; @@ -544,8 +597,13 @@ private async Task IsInIncidentAudienceAsync(ChatChannel channel, string u { foreach (var groupDispatch in call.GroupDispatches) { + var group = await _departmentGroupsService.GetGroupByIdAsync(groupDispatch.DepartmentGroupId, false); + if (group == null || group.DepartmentId != channel.DepartmentId) + continue; + var members = await _departmentGroupsService.GetAllMembersForGroupAsync(groupDispatch.DepartmentGroupId); - if (members != null && members.Any(m => string.Equals(m.UserId, userId, StringComparison.OrdinalIgnoreCase))) + if (members != null && members.Any(m => m.DepartmentId == channel.DepartmentId && + string.Equals(m.UserId, userId, StringComparison.OrdinalIgnoreCase))) return true; } } @@ -562,7 +620,8 @@ private async Task IsInIncidentAudienceAsync(ChatChannel channel, string u var assignments = await _incidentCommandService.GetAssignmentsForCallAsync(channel.DepartmentId, callId); if (assignments != null) { - foreach (var assignment in assignments.Where(a => !a.ReleasedOn.HasValue)) + foreach (var assignment in assignments.Where(a => !a.ReleasedOn.HasValue && + a.DepartmentId == channel.DepartmentId && a.CallId == callId)) { if (await MatchesResourceForIncidentAccessAsync(assignment, userId, activeUnitId, channel.DepartmentId)) return true; @@ -611,9 +670,10 @@ private async Task IsInLaneAudienceAsync(ChatChannel channel, string userI var assignments = await _incidentCommandService.GetAssignmentsForCallAsync(channel.DepartmentId, callId); if (assignments != null) { - foreach (var assignment in assignments.Where(a => !a.ReleasedOn.HasValue && a.CommandStructureNodeId == channel.CommandStructureNodeId)) + foreach (var assignment in assignments.Where(a => !a.ReleasedOn.HasValue && a.DepartmentId == channel.DepartmentId && + a.CallId == callId && a.CommandStructureNodeId == channel.CommandStructureNodeId)) { - if (MatchesResource(assignment, userId, activeUnitId)) + if (await MatchesResourceForIncidentAccessAsync(assignment, userId, activeUnitId, channel.DepartmentId)) return true; } } @@ -684,29 +744,18 @@ private async Task IsCommandStaffAsync(int departmentId, int callId, strin return roles != null && roles.Any(r => string.Equals(r.UserId, userId, StringComparison.OrdinalIgnoreCase) && !r.RemovedOn.HasValue); } - private static bool MatchesResource(ResourceAssignment assignment, string userId, int? activeUnitId) - { - if (assignment.ResourceKind == (int)ResourceAssignmentKind.RealPersonnel || assignment.ResourceKind == (int)ResourceAssignmentKind.LinkedDeptPersonnel) - return string.Equals(assignment.ResourceId, userId, StringComparison.OrdinalIgnoreCase); - - if (activeUnitId.HasValue && (assignment.ResourceKind == (int)ResourceAssignmentKind.RealUnit || assignment.ResourceKind == (int)ResourceAssignmentKind.LinkedDeptUnit)) - return assignment.ResourceId == activeUnitId.Value.ToString(); - - return false; - } - private async Task AddExplicitMemberAudienceAsync(ChatChannel channel, HashSet userIds) { var members = await _chatChannelMemberRepository.GetByChannelIdAsync(channel.ChatChannelId); if (members == null) return; - foreach (var member in members.Where(m => !m.RemovedOn.HasValue && !m.IsBanned)) + foreach (var member in members.Where(m => m.DepartmentId == channel.DepartmentId && !m.RemovedOn.HasValue && !m.IsBanned)) { if (member.ParticipantType == (int)ChatParticipantType.User) AddIfSet(userIds, member.UserId); else if (member.ParticipantType == (int)ChatParticipantType.Unit && member.UnitId.HasValue) - await AddUnitCrewAsync(member.UnitId.Value, userIds); + await AddUnitCrewAsync(member.UnitId.Value, channel.DepartmentId, userIds); } } @@ -716,7 +765,7 @@ private async Task AddCustomChannelAudienceAsync(ChatChannel channel, HashSet r.DepartmentId == channel.DepartmentId)) { switch ((ChatAccessRuleType)rule.RuleType) { @@ -727,9 +776,13 @@ private async Task AddCustomChannelAudienceAsync(ChatChannel channel, HashSet m.DepartmentId == channel.DepartmentId)) AddIfSet(userIds, m.UserId); } break; @@ -739,7 +792,7 @@ private async Task AddCustomChannelAudienceAsync(ChatChannel channel, HashSet m.DepartmentId == channel.DepartmentId)) AddIfSet(userIds, m.UserId); } break; @@ -757,7 +810,7 @@ private async Task AddIncidentAudienceAsync(ChatChannel channel, HashSet await AddCommandStaffAsync(channel.DepartmentId, callId, userIds); var call = await _callsService.GetCallByIdAsync(callId); - if (call != null) + if (call != null && call.DepartmentId == channel.DepartmentId) { if (call.Dispatches != null) foreach (var dispatch in call.Dispatches) @@ -767,9 +820,13 @@ private async Task AddIncidentAudienceAsync(ChatChannel channel, HashSet { foreach (var groupDispatch in call.GroupDispatches) { + var group = await _departmentGroupsService.GetGroupByIdAsync(groupDispatch.DepartmentGroupId, false); + if (group == null || group.DepartmentId != channel.DepartmentId) + continue; + var members = await _departmentGroupsService.GetAllMembersForGroupAsync(groupDispatch.DepartmentGroupId); if (members != null) - foreach (var m in members) + foreach (var m in members.Where(m => m.DepartmentId == channel.DepartmentId)) AddIfSet(userIds, m.UserId); } } @@ -780,20 +837,21 @@ private async Task AddIncidentAudienceAsync(ChatChannel channel, HashSet { var roleMembers = await _personnelRolesService.GetAllMembersOfRoleAsync(roleDispatch.RoleId); if (roleMembers != null) - foreach (var m in roleMembers) + foreach (var m in roleMembers.Where(m => m.DepartmentId == channel.DepartmentId)) AddIfSet(userIds, m.UserId); } } if (call.UnitDispatches != null) foreach (var unitDispatch in call.UnitDispatches) - await AddUnitCrewAsync(unitDispatch.UnitId, userIds); + await AddUnitCrewAsync(unitDispatch.UnitId, channel.DepartmentId, userIds); } var assignments = await _incidentCommandService.GetAssignmentsForCallAsync(channel.DepartmentId, callId); if (assignments != null) - foreach (var assignment in assignments.Where(a => !a.ReleasedOn.HasValue)) - await AddResourceAsync(assignment, userIds); + foreach (var assignment in assignments.Where(a => !a.ReleasedOn.HasValue && + a.DepartmentId == channel.DepartmentId && a.CallId == callId)) + await AddResourceAsync(assignment, channel.DepartmentId, userIds); } private async Task AddLaneAudienceAsync(ChatChannel channel, HashSet userIds) @@ -816,8 +874,9 @@ private async Task AddLaneAudienceAsync(ChatChannel channel, HashSet use var assignments = await _incidentCommandService.GetAssignmentsForCallAsync(channel.DepartmentId, callId); if (assignments != null) - foreach (var assignment in assignments.Where(a => !a.ReleasedOn.HasValue && a.CommandStructureNodeId == channel.CommandStructureNodeId)) - await AddResourceAsync(assignment, userIds); + foreach (var assignment in assignments.Where(a => !a.ReleasedOn.HasValue && a.DepartmentId == channel.DepartmentId && + a.CallId == callId && a.CommandStructureNodeId == channel.CommandStructureNodeId)) + await AddResourceAsync(assignment, channel.DepartmentId, userIds); } private async Task AddCommandStaffAsync(int departmentId, int callId, HashSet userIds) @@ -838,7 +897,7 @@ private async Task AddCommandStaffAsync(int departmentId, int callId, HashSet userIds) + private async Task AddResourceAsync(ResourceAssignment assignment, int departmentId, HashSet userIds) { if (assignment.ResourceKind == (int)ResourceAssignmentKind.RealPersonnel || assignment.ResourceKind == (int)ResourceAssignmentKind.LinkedDeptPersonnel) { @@ -847,12 +906,48 @@ private async Task AddResourceAsync(ResourceAssignment assignment, HashSet userIds) + private async Task AddIncidentCommanderLineRequesterAudienceAsync(ChatChannel channel, HashSet userIds) { + var members = await _chatChannelMemberRepository.GetByChannelIdAsync(channel.ChatChannelId); + if (members == null) + return; + + foreach (var member in members.Where(m => m.DepartmentId == channel.DepartmentId && !m.RemovedOn.HasValue && !m.IsBanned)) + { + if (member.ParticipantType == (int)ChatParticipantType.User && !string.IsNullOrWhiteSpace(member.UserId)) + { + if (await IsInIncidentAudienceAsync(channel, member.UserId, null)) + AddIfSet(userIds, member.UserId); + } + else if (member.ParticipantType == (int)ChatParticipantType.Unit && member.UnitId.HasValue) + { + var unit = await _unitsService.GetUnitByIdAsync(member.UnitId.Value); + if (unit == null || unit.DepartmentId != channel.DepartmentId) + continue; + + var activeRoles = await _unitsService.GetActiveRolesForUnitAsync(member.UnitId.Value); + if (activeRoles == null) + continue; + + foreach (var role in activeRoles.Where(r => !string.IsNullOrWhiteSpace(r.UserId))) + { + if (await IsInIncidentAudienceAsync(channel, role.UserId, member.UnitId.Value)) + AddIfSet(userIds, role.UserId); + } + } + } + } + + private async Task AddUnitCrewAsync(int unitId, int departmentId, HashSet userIds) + { + var unit = await _unitsService.GetUnitByIdAsync(unitId); + if (unit == null || unit.DepartmentId != departmentId) + return; + var activeRoles = await _unitsService.GetActiveRolesForUnitAsync(unitId); if (activeRoles != null) foreach (var role in activeRoles) @@ -861,7 +956,80 @@ private async Task AddUnitCrewAsync(int unitId, HashSet userIds) public async Task IsDepartmentAdminAsync(int departmentId, string userId) { - return await _authorizationService.CanUserModifyDepartmentAsync(userId, departmentId); + return await IsActiveDepartmentUserAsync(departmentId, userId) && + await _authorizationService.CanUserModifyDepartmentAsync(userId, departmentId); + } + + public async Task IsActiveDepartmentUserAsync(int departmentId, string userId) + { + return departmentId > 0 && !string.IsNullOrWhiteSpace(userId) && + await _departmentsService.IsUserInDepartmentAsync(departmentId, userId) && + await _authorizationService.IsUserValidWithinLimitsAsync(userId, departmentId); + } + + private async Task HasValidDepartmentScopeAsync(ChatChannel channel) + { + if (channel.DepartmentId <= 0) + return false; + + var channelType = (ChatChannelType)channel.ChannelType; + if (channelType == ChatChannelType.GroupDefault) + { + if (!channel.GroupId.HasValue) + return false; + + var group = await _departmentGroupsService.GetGroupByIdAsync(channel.GroupId.Value, false); + return group != null && group.DepartmentId == channel.DepartmentId; + } + + if (IsIncidentChannel(channelType)) + { + if (!channel.CallId.HasValue) + return false; + + var call = await _callsService.GetCallByIdAsync(channel.CallId.Value); + if (call == null || call.DepartmentId != channel.DepartmentId) + return false; + + if (channelType == ChatChannelType.IncidentLane) + { + if (string.IsNullOrWhiteSpace(channel.CommandStructureNodeId)) + return false; + + var nodes = await _incidentCommandService.GetNodesForCallAsync(channel.DepartmentId, channel.CallId.Value); + return nodes != null && nodes.Any(n => n.DepartmentId == channel.DepartmentId && n.CallId == channel.CallId.Value && + string.Equals(n.CommandStructureNodeId, channel.CommandStructureNodeId, StringComparison.OrdinalIgnoreCase)); + } + + return true; + } + + if (channelType == ChatChannelType.UnitDispatch) + { + var unitId = await GetUnitDispatchChannelUnitIdAsync(channel); + if (!unitId.HasValue) + return false; + + var unit = await _unitsService.GetUnitByIdAsync(unitId.Value); + return unit != null && unit.DepartmentId == channel.DepartmentId; + } + + return true; + } + + private static bool IsIncidentChannel(ChatChannelType channelType) + { + return channelType == ChatChannelType.Incident || channelType == ChatChannelType.IncidentLane || + channelType == ChatChannelType.IncidentCommand || channelType == ChatChannelType.IncidentLeads || + channelType == ChatChannelType.IncidentDispatch || channelType == ChatChannelType.IncidentCommanderLine; + } + + private static bool IsDispatchVisibleChannel(ChatChannelType channelType) + { + return channelType == ChatChannelType.DepartmentDefault || channelType == ChatChannelType.GroupDefault || + channelType == ChatChannelType.Incident || channelType == ChatChannelType.IncidentLane || + channelType == ChatChannelType.IncidentCommand || channelType == ChatChannelType.IncidentLeads || + channelType == ChatChannelType.IncidentDispatch || channelType == ChatChannelType.UnitDispatch; } private static void AddIfSet(HashSet set, string userId) diff --git a/Core/Resgrid.Services/ChatProvisioningEventService.cs b/Core/Resgrid.Services/ChatProvisioningEventService.cs index bc916ab2e..8c5dfaeb5 100644 --- a/Core/Resgrid.Services/ChatProvisioningEventService.cs +++ b/Core/Resgrid.Services/ChatProvisioningEventService.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Autofac; using Resgrid.Framework; @@ -38,6 +39,12 @@ public ChatProvisioningEventService(IEventAggregator eventAggregator, ILifetimeS _eventAggregator.AddAsyncListener(OnIncidentClosedAsync); _eventAggregator.AddAsyncListener(OnLaneLeadChangedAsync); _eventAggregator.AddAsyncListener(OnIncidentReopenedAsync); + // These two are published through SendMessage (the synchronous event path), so register + // synchronous bridges and wait for the fail-safe RunAsync handler to finish. Authorization + // epochs must rotate before the mutation returns; fire-and-forget would leave a payload leak + // window for already-joined SignalR clients. + _eventAggregator.AddListener(message => OnDepartmentSecurityChangedAsync(message).GetAwaiter().GetResult()); + _eventAggregator.AddListener(message => OnIncidentAuthorizationChangedAsync(message).GetAwaiter().GetResult()); } private Task OnCallAddedAsync(CallAddedEvent message) @@ -164,6 +171,70 @@ private Task OnIncidentReopenedAsync(IncidentReopenedEvent message) }); } + private Task OnDepartmentSecurityChangedAsync(SecurityRefreshEvent message) + { + // DepartmentsService emits this once per membership/admin/visibility refresh (alongside + // the other visibility matrices). Handle one type only so a single change does not rotate + // every chat group four times. + if (message == null || message.DepartmentId <= 0 || message.Type != SecurityCacheTypes.WhoCanViewPersonnel) + return Task.CompletedTask; + + return RunAsync(async scope => + { + var channelRepository = scope.Resolve(); + var permissionService = scope.Resolve(); + var channels = await channelRepository.GetAllByDepartmentIdAsync(message.DepartmentId, true); + + if (channels != null) + { + foreach (var channel in channels.Where(c => c != null)) + await permissionService.InvalidateChannelCacheAsync(channel.ChatChannelId); + } + + _eventAggregator.SendMessage(new ChatEventRaised + { + DepartmentId = message.DepartmentId, + Kind = ChatEventKinds.ChannelUpdated, + PayloadJson = Newtonsoft.Json.JsonConvert.SerializeObject(new + { + DepartmentId = message.DepartmentId, + AuthorizationChanged = true + }) + }); + }); + } + + private Task OnIncidentAuthorizationChangedAsync(IncidentCommandUpdatedEvent message) + { + if (message == null || message.DepartmentId <= 0 || message.CallId <= 0) + return Task.CompletedTask; + + return RunAsync(async scope => + { + var channelRepository = scope.Resolve(); + var permissionService = scope.Resolve(); + var channels = await channelRepository.GetByCallIdAsync(message.CallId); + + if (channels != null) + { + foreach (var channel in channels.Where(c => c != null && c.DepartmentId == message.DepartmentId)) + await permissionService.InvalidateChannelCacheAsync(channel.ChatChannelId); + } + + _eventAggregator.SendMessage(new ChatEventRaised + { + DepartmentId = message.DepartmentId, + Kind = ChatEventKinds.ChannelUpdated, + PayloadJson = Newtonsoft.Json.JsonConvert.SerializeObject(new + { + DepartmentId = message.DepartmentId, + CallId = message.CallId, + AuthorizationChanged = true + }) + }); + }); + } + /// /// Runs a provisioning action in its own DI lifetime scope so each event gets fresh scoped /// services (and their own unit-of-work/DB connection), disposed when the action completes. diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index db7443c64..a58cc80a2 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -1625,7 +1625,7 @@ await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId, var chatChannelService = ServiceLocator.Current.GetInstance(); var laneChannel = (await ServiceLocator.Current.GetInstance().GetByCommandStructureNodeIdAsync(commandStructureNodeId)); if (laneChannel != null && !laneChannel.IsArchived) - await chatChannelService.SetChannelArchivedAsync(laneChannel.ChatChannelId, true, userId, cancellationToken); + await chatChannelService.SetChannelArchivedAsync(laneChannel.DepartmentId, laneChannel.ChatChannelId, true, userId, cancellationToken); } catch (Exception ex) { diff --git a/Core/Resgrid.Services/ModerationService.cs b/Core/Resgrid.Services/ModerationService.cs index 3594a3d5e..872d9856e 100644 --- a/Core/Resgrid.Services/ModerationService.cs +++ b/Core/Resgrid.Services/ModerationService.cs @@ -440,7 +440,13 @@ private async Task LoadEvidenceAsync(int departmentId, strin var attachmentMetadata = await _chatAttachmentRepository.GetMetadataByMessageIdsAsync(new[] { itemId }); var firstAttachment = attachmentMetadata?.FirstOrDefault(); if (firstAttachment != null) - attachment = await _chatAttachmentRepository.GetByIdAsync(firstAttachment.ChatAttachmentId); + { + var candidate = await _chatAttachmentRepository.GetByIdAsync(firstAttachment.ChatAttachmentId); + if (candidate != null && candidate.DepartmentId == departmentId && + string.Equals(candidate.ChatMessageId, message.ChatMessageId, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.ChatChannelId, message.ChatChannelId, StringComparison.OrdinalIgnoreCase)) + attachment = candidate; + } return new ModerationEvidence { @@ -566,7 +572,7 @@ private async Task RemoveLiveContentAsync(ModerationRequest request, strin switch ((ModerationItemType)request.ItemType) { case ModerationItemType.ChatMessage: - return await _chatMessageService.DeleteMessageAsync(request.ItemId, byUserId, true, + return await _chatMessageService.DeleteMessageAsync(request.DepartmentId, request.ItemId, byUserId, true, ModeratedChatMessage, cancellationToken); case ModerationItemType.Message: diff --git a/Core/Resgrid.Services/PermissionGateServiceBase.cs b/Core/Resgrid.Services/PermissionGateServiceBase.cs index 39b241109..0eb78f3fa 100644 --- a/Core/Resgrid.Services/PermissionGateServiceBase.cs +++ b/Core/Resgrid.Services/PermissionGateServiceBase.cs @@ -57,8 +57,8 @@ protected async Task IsAllowedAsync(int departmentId, string userId) try { var cached = await _cacheProvider.GetStringAsync(cacheKey); - if (cached == "1") - return true; + // Permission rows and department/group/role membership can be revoked independently of + // this cache. A negative verdict is safe to reuse; a positive one must be evaluated live. if (cached == "0") return false; } @@ -72,7 +72,8 @@ protected async Task IsAllowedAsync(int departmentId, string userId) try { - await _cacheProvider.SetStringAsync(cacheKey, allowed ? "1" : "0", CacheLength); + if (!allowed) + await _cacheProvider.SetStringAsync(cacheKey, "0", CacheLength); } catch (Exception ex) { diff --git a/Core/Resgrid.Services/PermissionsService.cs b/Core/Resgrid.Services/PermissionsService.cs index 2dcde7b21..7ba4ec69e 100644 --- a/Core/Resgrid.Services/PermissionsService.cs +++ b/Core/Resgrid.Services/PermissionsService.cs @@ -3,7 +3,11 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using CommonServiceLocator; +using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -49,7 +53,53 @@ public async Task SetPermissionForDepartmentAsync(int departmentId, permission.UpdatedBy = userId; permission.UpdatedOn = DateTime.UtcNow; - return await _permissionsRepository.SaveOrUpdateAsync(permission, cancellationToken); + var saved = await _permissionsRepository.SaveOrUpdateAsync(permission, cancellationToken); + + if (saved != null && type == PermissionTypes.DispatchAppLogin) + await RotateDispatchChatAccessAsync(departmentId); + + return saved; + } + + private static async Task RotateDispatchChatAccessAsync(int departmentId) + { + if (departmentId <= 0 || !ServiceLocator.IsLocationProviderSet) + return; + + try + { + var channelRepository = ServiceLocator.Current.GetInstance(); + var permissionService = ServiceLocator.Current.GetInstance(); + var channels = await channelRepository.GetAllByDepartmentIdAsync(departmentId, true); + + if (channels != null) + { + foreach (var channel in channels.Where(c => c != null && IsDispatchVisibleChannel((ChatChannelType)c.ChannelType))) + await permissionService.InvalidateChannelCacheAsync(channel.ChatChannelId); + } + + ServiceLocator.Current.GetInstance().SendMessage(new ChatEventRaised + { + DepartmentId = departmentId, + Kind = ChatEventKinds.ChannelUpdated, + PayloadJson = Newtonsoft.Json.JsonConvert.SerializeObject(new { DepartmentId = departmentId, AuthorizationChanged = true }) + }); + } + catch (Exception ex) + { + // The permission write is authoritative and must not be rolled back by a realtime refresh + // failure. REST authorization already evaluates the new row live; log and let clients + // reconnect if eventing/cache infrastructure is unavailable. + Logging.LogException(ex); + } + } + + private static bool IsDispatchVisibleChannel(ChatChannelType channelType) + { + return channelType == ChatChannelType.DepartmentDefault || channelType == ChatChannelType.GroupDefault || + channelType == ChatChannelType.Incident || channelType == ChatChannelType.IncidentLane || + channelType == ChatChannelType.IncidentCommand || channelType == ChatChannelType.IncidentLeads || + channelType == ChatChannelType.IncidentDispatch || channelType == ChatChannelType.UnitDispatch; } public bool IsUserAllowed(Permission permission, bool isUserDepartmentAdmin, bool isUserGroupAdmin, diff --git a/Core/Resgrid.Services/PushService.cs b/Core/Resgrid.Services/PushService.cs index eb7a60f6a..b47983188 100644 --- a/Core/Resgrid.Services/PushService.cs +++ b/Core/Resgrid.Services/PushService.cs @@ -17,10 +17,12 @@ public class PushService : IPushService private readonly IUserProfileService _userProfileService; private readonly INovuProvider _novuProvider; private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly IUnitsService _unitsService; public PushService(IPushLogsService pushLogsService, INotificationProvider notificationProvider, IUserProfileService userProfileService, IUnitNotificationProvider unitNotificationProvider, - INovuProvider novuProvider, IDepartmentSettingsService departmentSettingsService) + INovuProvider novuProvider, IDepartmentSettingsService departmentSettingsService, + IUnitsService unitsService) { _pushLogsService = pushLogsService; _notificationProvider = notificationProvider; @@ -28,6 +30,7 @@ public PushService(IPushLogsService pushLogsService, INotificationProvider notif _unitNotificationProvider = unitNotificationProvider; _novuProvider = novuProvider; _departmentSettingsService = departmentSettingsService; + _unitsService = unitsService; } public async Task Register(PushUri pushUri) @@ -100,22 +103,67 @@ await _novuProvider.CreateICUserSubscriber(pushUri.UserId, code, pushUri.Departm public async Task RegisterUnit(PushUri pushUri) { + // Same reasoning as Register: a unit whose token never lands on its Novu subscriber gets no + // dispatch pushes at all, and every bare `false` below used to leave nothing behind to find. if (pushUri == null || !pushUri.UnitId.HasValue || string.IsNullOrWhiteSpace(pushUri.DeviceId) || string.IsNullOrWhiteSpace(pushUri.PushLocation)) + { + Framework.Logging.LogWarning($"PushService.RegisterUnit: incomplete registration (unitId {pushUri?.UnitId}, platform {pushUri?.PlatformType}, hasToken {!string.IsNullOrWhiteSpace(pushUri?.DeviceId)}, prefix '{pushUri?.PushLocation}'), skipped."); return false; + } var unitId = pushUri.UnitId.Value; var code = pushUri.PushLocation; + // The user path creates its subscriber before writing credentials; the unit path never did, so a + // unit that had not been through some other Novu call had its credential write rejected against + // a subscriber that did not exist. + await EnsureUnitSubscriber(unitId, code, pushUri.DeviceId); + + bool registered; + // 1) iOS -> APNS if (pushUri.PlatformType == (int)Platforms.iOS) - return await _novuProvider.UpdateUnitSubscriberApns(unitId, code, pushUri.DeviceId); - + { + registered = await _novuProvider.UpdateUnitSubscriberApns(unitId, code, pushUri.DeviceId); + } // 2) Android -> FCM - if (pushUri.PlatformType == (int)Platforms.Android) - return await _novuProvider.UpdateUnitSubscriberFcm(unitId, code, pushUri.DeviceId); - + else if (pushUri.PlatformType == (int)Platforms.Android) + { + registered = await _novuProvider.UpdateUnitSubscriberFcm(unitId, code, pushUri.DeviceId); + } // 3) TODO: Web Push (other platforms) - return false; + else + { + Framework.Logging.LogWarning($"PushService.RegisterUnit: unsupported platform {pushUri.PlatformType} for unit {unitId} (prefix '{code}'), no push channel registered."); + return false; + } + + if (!registered) + Framework.Logging.LogError($"PushService.RegisterUnit: Novu rejected the credential write for unit {unitId} (platform {pushUri.PlatformType}, prefix '{code}'); subscriber will have no configured push channel."); + + return registered; + } + + private async Task EnsureUnitSubscriber(int unitId, string code, string deviceId) + { + try + { + // The unit's own record is the authority on its name and department; the PushUri carries a + // DepartmentId that is unmapped and set by whoever built the message. + var unit = await _unitsService.GetUnitByIdAsync(unitId); + + if (unit == null) + { + Framework.Logging.LogWarning($"PushService.RegisterUnit: unit {unitId} (prefix '{code}') was not found, its Novu subscriber could not be created."); + return; + } + + await _novuProvider.CreateUnitSubscriber(unitId, code, unit.DepartmentId, unit.Name, deviceId); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } } public async Task UnRegisterUnit(PushUri pushUri) diff --git a/Providers/Resgrid.Providers.Claims/ChatAuthorizationPolicyExtensions.cs b/Providers/Resgrid.Providers.Claims/ChatAuthorizationPolicyExtensions.cs new file mode 100644 index 000000000..6bf45bda0 --- /dev/null +++ b/Providers/Resgrid.Providers.Claims/ChatAuthorizationPolicyExtensions.cs @@ -0,0 +1,24 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; + +namespace Resgrid.Providers.Claims +{ + /// Shared identity and permission requirements for every chat transport. + public static class ChatAuthorizationPolicyExtensions + { + public static AuthorizationPolicyBuilder RequireChatAccessClaims(this AuthorizationPolicyBuilder policy) + { + return policy + .RequireAuthenticatedUser() + .RequireClaim(ResgridClaimTypes.Resources.Messages, ResgridClaimTypes.Actions.View) + .RequireAssertion(context => + { + var userId = context.User.FindFirst(ClaimTypes.PrimarySid)?.Value; + var departmentIdClaim = context.User.FindFirst(ClaimTypes.PrimaryGroupSid)?.Value; + + return !string.IsNullOrWhiteSpace(userId) && + int.TryParse(departmentIdClaim, out var departmentId) && departmentId > 0; + }); + } + } +} diff --git a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs index 86896e64d..9f56c846d 100644 --- a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs +++ b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs @@ -8,6 +8,7 @@ using Resgrid.Model.Providers; using Resgrid.Providers.Bus.Models; using SharpCompress.Common; +using System.Net; using System.Text; using System.Text.RegularExpressions; @@ -132,7 +133,22 @@ private async Task CreateSubscriber(string id, int departmentId, string em var response = await httpClient.PostAsync(requestUrl, content); - return response.IsSuccessStatusCode; + // A rejected create left the subscriber missing entirely, and every later credential + // write and trigger against it fails for a reason that looks unrelated. Only the 409 is + // expected: re-registering a device re-runs this, and Novu answers an already-present + // subscriber with a conflict, which is the steady state rather than a fault. + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == HttpStatusCode.Conflict) + return true; + + var error = await response.Content.ReadAsStringAsync(); + Logging.LogError($"Novu subscriber create failed ({(int)response.StatusCode} {response.StatusCode}) subscriber '{id}' department {departmentId}: {DescribeErrorBody(error)}"); + + return false; + } + + return true; } } catch (Exception e) diff --git a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs index 14eae5670..1ea1426b3 100644 --- a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs @@ -81,6 +81,8 @@ private void BuildService() // Cross-tenant validation passes by default; negative tests override this. _departmentsServiceMock.Setup(x => x.IsUserInDepartmentAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + _chatPermissionServiceMock.Setup(x => x.CanModerateChannelAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); // Batch membership check (ad-hoc group creation): by default every queried id is a member. _departmentsServiceMock @@ -148,6 +150,34 @@ public async Task missing_department_channel_should_be_created_named_after_depar } } + [TestFixture] + public class when_saving_department_chat_settings : with_the_chat_channel_service + { + [Test] + public async Task forged_settings_department_should_be_rejected_before_repository_access() + { + var settings = new ChatDepartmentSetting { DepartmentId = 2 }; + + var result = await _chatChannelService.SaveDepartmentSettingsAsync(1, "user-a", settings); + + result.Should().BeNull(); + _chatPermissionServiceMock.Verify(x => x.IsDepartmentAdminAsync(It.IsAny(), It.IsAny()), Times.Never); + _chatDepartmentSettingRepositoryMock.Verify(x => x.GetByDepartmentIdAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task non_admin_should_not_save_settings() + { + var settings = new ChatDepartmentSetting { DepartmentId = 1 }; + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false); + + var result = await _chatChannelService.SaveDepartmentSettingsAsync(1, "user-a", settings); + + result.Should().BeNull(); + _chatDepartmentSettingRepositoryMock.Verify(x => x.GetByDepartmentIdAsync(It.IsAny()), Times.Never); + } + } + [TestFixture] public class when_ensuring_group_channels : with_the_chat_channel_service { @@ -175,6 +205,17 @@ public async Task existing_group_channel_should_be_returned_without_insert() [TestFixture] public class when_ensuring_chatbot_channels : with_the_chat_channel_service { + [Test] + public async Task disabled_department_member_should_not_get_a_chatbot_channel() + { + _departmentsServiceMock.Setup(x => x.IsUserDisabledAsync("user-a", 1)).ReturnsAsync(true); + + var result = await _chatChannelService.EnsureChatbotChannelAsync(1, "user-a"); + + result.Should().BeNull(); + _chatChannelRepositoryMock.Verify(x => x.GetChatbotChannelAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task missing_chatbot_channel_should_create_channel_with_owner_and_bot_members() { @@ -225,6 +266,17 @@ public async Task existing_chatbot_channel_with_owner_member_should_not_insert_m [TestFixture] public class when_getting_or_creating_direct_message_channels : with_the_chat_channel_service { + [Test] + public void disabled_target_user_should_be_rejected() + { + _departmentsServiceMock.Setup(x => x.IsUserDisabledAsync("user-b", 1)).ReturnsAsync(true); + + Func act = async () => await _chatChannelService.GetOrCreateDirectMessageChannelAsync(1, "user-a", "user-b", null); + + act.Should().ThrowAsync(); + _chatChannelRepositoryMock.Verify(x => x.GetByDmKeyAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task existing_dm_key_should_return_existing_channel_without_insert() { @@ -367,12 +419,14 @@ public class when_setting_notification_preferences : with_the_chat_channel_servi [Test] public async Task missing_member_row_should_be_created_then_preference_updated() { - _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("channel-1")).ReturnsAsync(new ChatChannel + var channel = new ChatChannel { ChatChannelId = "channel-1", DepartmentId = 1, ChannelType = (int)ChatChannelType.DepartmentDefault - }); + }; + _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("channel-1")).ReturnsAsync(channel); + _chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(channel, "user-a", null)).ReturnsAsync(true); _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync("channel-1", "user-a")).ReturnsAsync((ChatChannelMember)null); _chatChannelMemberRepositoryMock.Setup(x => x.SetMemberNotificationPreferenceAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); @@ -407,7 +461,7 @@ public void direct_message_channel_should_reject_member_adds() { _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("dm-1")).ReturnsAsync(CreateChannel("dm-1", ChatChannelType.DirectMessage)); - Func act = async () => await _chatChannelService.AddMembersAsync("dm-1", new List { "user-b" }, "user-a"); + Func act = async () => await _chatChannelService.AddMembersAsync(1, "dm-1", new List { "user-b" }, "user-a"); act.Should().ThrowAsync(); } @@ -419,7 +473,7 @@ public void custom_locked_non_moderator_should_be_rejected() _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("custom-1")).ReturnsAsync(channel); _chatPermissionServiceMock.Setup(x => x.CanModerateChannelAsync(channel, "user-a")).ReturnsAsync(false); - Func act = async () => await _chatChannelService.AddMembersAsync("custom-1", new List { "user-b" }, "user-a"); + Func act = async () => await _chatChannelService.AddMembersAsync(1, "custom-1", new List { "user-b" }, "user-a"); act.Should().ThrowAsync(); } @@ -432,7 +486,7 @@ public async Task custom_locked_moderator_should_add_members() _chatPermissionServiceMock.Setup(x => x.CanModerateChannelAsync(channel, "user-a")).ReturnsAsync(true); _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync("custom-1", "user-b")).ReturnsAsync((ChatChannelMember)null); - var result = await _chatChannelService.AddMembersAsync("custom-1", new List { "user-b" }, "user-a"); + var result = await _chatChannelService.AddMembersAsync(1, "custom-1", new List { "user-b" }, "user-a"); result.Should().HaveCount(1); _chatChannelMemberRepositoryMock.Verify(x => x.InsertAsync(It.Is(m => @@ -447,7 +501,7 @@ public void cross_department_member_should_be_rejected() _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("adhoc-1")).ReturnsAsync(channel); _departmentsServiceMock.Setup(x => x.IsUserInDepartmentAsync(1, "outsider")).ReturnsAsync(false); - Func act = async () => await _chatChannelService.AddMembersAsync("adhoc-1", new List { "outsider" }, "user-a"); + Func act = async () => await _chatChannelService.AddMembersAsync(1, "adhoc-1", new List { "outsider" }, "user-a"); act.Should().ThrowAsync(); } @@ -468,7 +522,7 @@ public async Task removed_member_should_be_reactivated_with_targeted_update() }); _chatChannelMemberRepositoryMock.Setup(x => x.SetMemberActiveAsync("member-1", true, It.IsAny())).ReturnsAsync(true); - var result = await _chatChannelService.AddMembersAsync("adhoc-1", new List { "user-b" }, "user-a"); + var result = await _chatChannelService.AddMembersAsync(1, "adhoc-1", new List { "user-b" }, "user-a"); result.Should().HaveCount(1); _chatChannelMemberRepositoryMock.Verify(x => x.SetMemberActiveAsync("member-1", true, It.IsAny()), Times.Once); @@ -476,18 +530,79 @@ public async Task removed_member_should_be_reactivated_with_targeted_update() } } + [TestFixture] + public class when_mutating_channels : with_the_chat_channel_service + { + [Test] + public async Task authenticated_department_should_override_a_forged_channel_identifier() + { + var foreignChannel = new ChatChannel + { + ChatChannelId = "department-2-channel", + DepartmentId = 2, + ChannelType = (int)ChatChannelType.AdHocGroup + }; + _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync(foreignChannel.ChatChannelId)).ReturnsAsync(foreignChannel); + + var result = await _chatChannelService.UpdateChannelAsync(1, foreignChannel.ChatChannelId, "forged", null, "user-a"); + + result.Should().BeNull(); + _chatPermissionServiceMock.Verify(x => x.CanModerateChannelAsync(It.IsAny(), It.IsAny()), Times.Never); + _chatChannelRepositoryMock.Verify(x => x.UpdateChannelInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + } + [TestFixture] public class when_ensuring_member_state : with_the_chat_channel_service { + [Test] + public async Task department_membership_alone_should_not_self_grant_an_inaccessible_channel() + { + var channel = new ChatChannel + { + ChatChannelId = "incident-1", + DepartmentId = 1, + ChannelType = (int)ChatChannelType.Incident, + CallId = 10 + }; + _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync(channel.ChatChannelId)).ReturnsAsync(channel); + _chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(channel, "user-a", null)).ReturnsAsync(false); + + var result = await _chatChannelService.EnsureMemberStateAsync(channel.ChatChannelId, 1, "user-a", null); + + result.Should().BeNull(); + _chatChannelMemberRepositoryMock.Verify(x => x.InsertAsync(It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task channel_from_another_department_should_not_create_member_state() + { + _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("dept-2")).ReturnsAsync(new ChatChannel + { + ChatChannelId = "dept-2", + DepartmentId = 2, + ChannelType = (int)ChatChannelType.DepartmentDefault + }); + + var result = await _chatChannelService.EnsureMemberStateAsync("dept-2", 1, "user-a", null); + + result.Should().BeNull(); + _chatChannelMemberRepositoryMock.Verify(x => x.InsertAsync(It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public void invite_only_channel_without_membership_should_throw() { - _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("dm-1")).ReturnsAsync(new ChatChannel + var channel = new ChatChannel { ChatChannelId = "dm-1", DepartmentId = 1, ChannelType = (int)ChatChannelType.DirectMessage - }); + }; + _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("dm-1")).ReturnsAsync(channel); + _chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(channel, "user-a", null)).ReturnsAsync(true); _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync("dm-1", "user-a")).ReturnsAsync((ChatChannelMember)null); Func act = async () => await _chatChannelService.EnsureMemberStateAsync("dm-1", 1, "user-a", null); @@ -499,12 +614,14 @@ public void invite_only_channel_without_membership_should_throw() [Test] public async Task invite_only_channel_with_removed_membership_should_reactivate() { - _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("dm-1")).ReturnsAsync(new ChatChannel + var channel = new ChatChannel { ChatChannelId = "dm-1", DepartmentId = 1, ChannelType = (int)ChatChannelType.DirectMessage - }); + }; + _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("dm-1")).ReturnsAsync(channel); + _chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(channel, "user-a", null)).ReturnsAsync(true); _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync("dm-1", "user-a")).ReturnsAsync(new ChatChannelMember { ChatChannelMemberId = "member-1", @@ -527,12 +644,14 @@ public async Task invite_only_channel_with_removed_membership_should_reactivate( [Test] public async Task implicit_channel_without_membership_should_create_row() { - _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("dept-1")).ReturnsAsync(new ChatChannel + var channel = new ChatChannel { ChatChannelId = "dept-1", DepartmentId = 1, ChannelType = (int)ChatChannelType.DepartmentDefault - }); + }; + _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("dept-1")).ReturnsAsync(channel); + _chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(channel, "user-a", null)).ReturnsAsync(true); _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync("dept-1", "user-a")).ReturnsAsync((ChatChannelMember)null); var result = await _chatChannelService.EnsureMemberStateAsync("dept-1", 1, "user-a", null); @@ -544,6 +663,29 @@ public async Task implicit_channel_without_membership_should_create_row() } } + [TestFixture] + public class when_listing_channel_members : with_the_chat_channel_service + { + [Test] + public async Task member_rows_from_another_department_should_be_filtered_out() + { + _chatChannelRepositoryMock.Setup(x => x.GetByIdAsync("channel-1")).ReturnsAsync(new ChatChannel + { + ChatChannelId = "channel-1", + DepartmentId = 1 + }); + _chatChannelMemberRepositoryMock.Setup(x => x.GetByChannelIdAsync("channel-1")).ReturnsAsync(new List + { + new ChatChannelMember { ChatChannelMemberId = "member-1", ChatChannelId = "channel-1", DepartmentId = 1, UserId = "user-a" }, + new ChatChannelMember { ChatChannelMemberId = "member-2", ChatChannelId = "channel-1", DepartmentId = 2, UserId = "user-b" } + }); + + var result = await _chatChannelService.GetMembersAsync("channel-1"); + + result.Should().ContainSingle().Which.ChatChannelMemberId.Should().Be("member-1"); + } + } + [TestFixture] public class when_listing_channels_for_users : with_the_chat_channel_service { @@ -582,6 +724,25 @@ public async Task department_admin_should_get_every_group_channel_provisioned() _departmentGroupsServiceMock.Verify(x => x.GetGroupForUserAsync(It.IsAny(), It.IsAny()), Times.Never); } + [Test] + public async Task dispatcher_should_get_every_department_group_channel_provisioned() + { + SetupDepartmentChannel(); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "dispatcher-a")).ReturnsAsync(false); + _chatPermissionServiceMock.Setup(x => x.CanAccessDepartmentOperationalChannelsAsync(1, "dispatcher-a")).ReturnsAsync(true); + _departmentGroupsServiceMock.Setup(x => x.GetAllGroupsForDepartmentAsync(1)).ReturnsAsync(new List + { + new DepartmentGroup { DepartmentGroupId = 9, DepartmentId = 1, Name = "Station 1" }, + new DepartmentGroup { DepartmentGroupId = 10, DepartmentId = 1, Name = "Station 2" } + }); + _chatChannelRepositoryMock.Setup(x => x.GetByGroupIdAsync(It.IsAny())).ReturnsAsync((ChatChannel)null); + + var result = await _chatChannelService.GetChannelsForUserAsync(1, "dispatcher-a", null); + + result.Should().Contain(c => c.ChannelType == (int)ChatChannelType.GroupDefault && c.GroupId == 9); + result.Should().Contain(c => c.ChannelType == (int)ChatChannelType.GroupDefault && c.GroupId == 10); + } + [Test] public async Task admin_existing_group_channels_should_come_from_bulk_load_without_per_group_lookups() { @@ -657,12 +818,69 @@ public async Task active_unit_should_see_channels_where_the_unit_is_the_member() new ChatChannelMember { ChatChannelMemberId = "m1", ChatChannelId = "dm-unit-7", DepartmentId = 1, ParticipantType = (int)ChatParticipantType.Unit, UnitId = 7 } }); _chatChannelRepositoryMock.Setup(x => x.GetByIdsAsync(It.Is>(ids => ids.Contains("dm-unit-7")))).ReturnsAsync(new List { unitDm }); + _chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(unitDm, "user-a", 7)).ReturnsAsync(true); var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", 7); result.Should().Contain(c => c.ChatChannelId == "dm-unit-7"); } + [Test] + public async Task stale_incident_member_row_should_not_keep_the_channel_visible_after_unassignment() + { + SetupDepartmentChannel(); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false); + + var incident = new ChatChannel + { + ChatChannelId = "incident-42", + DepartmentId = 1, + ChannelType = (int)ChatChannelType.Incident, + CallId = 42 + }; + _chatChannelMemberRepositoryMock.Setup(x => x.GetActiveByUserIdAsync(1, "user-a")).ReturnsAsync(new List + { + new ChatChannelMember + { + ChatChannelMemberId = "read-state-1", + ChatChannelId = incident.ChatChannelId, + DepartmentId = 1, + ParticipantType = (int)ChatParticipantType.User, + UserId = "user-a" + } + }); + _chatChannelRepositoryMock.Setup(x => x.GetByIdsAsync(It.IsAny>())).ReturnsAsync(new List { incident }); + _chatPermissionServiceMock.Setup(x => x.CanAccessChannelAsync(incident, "user-a", null)).ReturnsAsync(false); + + var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", null); + + result.Should().NotContain(c => c.ChatChannelId == incident.ChatChannelId); + } + + [Test] + public async Task cross_department_channel_from_a_member_row_should_not_be_listed() + { + SetupDepartmentChannel(); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false); + + var foreignChannel = new ChatChannel + { + ChatChannelId = "foreign-dm", + DepartmentId = 2, + ChannelType = (int)ChatChannelType.DirectMessage + }; + _chatChannelMemberRepositoryMock.Setup(x => x.GetActiveByUserIdAsync(1, "user-a")).ReturnsAsync(new List + { + new ChatChannelMember { ChatChannelId = foreignChannel.ChatChannelId, DepartmentId = 1, UserId = "user-a" } + }); + _chatChannelRepositoryMock.Setup(x => x.GetByIdsAsync(It.IsAny>())).ReturnsAsync(new List { foreignChannel }); + + var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", null); + + result.Should().NotContain(c => c.ChatChannelId == foreignChannel.ChatChannelId); + _chatPermissionServiceMock.Verify(x => x.CanAccessChannelAsync(foreignChannel, It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task active_unit_the_user_does_not_crew_should_not_expose_unit_channels() { @@ -819,6 +1037,17 @@ public async Task a_unit_from_another_department_should_not_get_a_channel() [TestFixture] public class when_creating_ad_hoc_group_channels : with_the_chat_channel_service { + [Test] + public void disabled_member_should_be_rejected_before_channel_creation() + { + _departmentsServiceMock.Setup(x => x.IsUserDisabledAsync("user-b", 1)).ReturnsAsync(true); + + Func act = async () => await _chatChannelService.CreateAdHocGroupChannelAsync(1, "user-a", "Strike Team", new List { "user-b" }); + + act.Should().ThrowAsync(); + _chatChannelRepositoryMock.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task creator_should_be_inserted_as_moderator() { diff --git a/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs b/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs index da963940e..63a997c77 100644 --- a/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs @@ -40,6 +40,7 @@ public class with_the_commander_line : TestBase protected Mock _callsServiceMock; protected Mock _dispatchAccessServiceMock; protected Mock _authorizationServiceMock; + protected Mock _channelPermissionServiceMock; protected with_the_commander_line() { @@ -61,14 +62,25 @@ private void BuildServices() _callsServiceMock = new Mock(); _dispatchAccessServiceMock = new Mock(); _authorizationServiceMock = new Mock(); + _channelPermissionServiceMock = new Mock(); var cacheProviderMock = new Mock(); + var departmentsServiceMock = new Mock(); cacheProviderMock.Setup(x => x.GetStringAsync(It.IsAny())).ReturnsAsync((string)null); cacheProviderMock.Setup(x => x.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + departmentsServiceMock.Setup(x => x.IsUserInDepartmentAsync(DepartmentId, It.IsAny())).ReturnsAsync(true); + departmentsServiceMock + .Setup(x => x.GetMemberUserIdsInDepartmentAsync(DepartmentId, It.IsAny>())) + .ReturnsAsync((int _, IEnumerable ids) => ids == null + ? new HashSet() + : new HashSet(ids, StringComparer.OrdinalIgnoreCase)); _authorizationServiceMock.Setup(x => x.CanUserModifyDepartmentAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + _authorizationServiceMock.Setup(x => x.IsUserValidWithinLimitsAsync(It.IsAny(), DepartmentId)).ReturnsAsync(true); _dispatchAccessServiceMock.Setup(x => x.CanUseDispatchAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); _dispatchAccessServiceMock.Setup(x => x.GetDispatchUserIdsAsync(It.IsAny())).ReturnsAsync(new List()); + _channelPermissionServiceMock.Setup(x => x.CanAccessIncidentAsync(DepartmentId, CallId, It.IsAny(), It.IsAny())).ReturnsAsync(true); + _channelPermissionServiceMock.Setup(x => x.CanSendAsUnitAsync(It.IsAny(), It.IsAny(), DepartmentId)).ReturnsAsync(true); // The atomic channel+members insert echoes the channel back, and the member rows stay // inspectable through the callback argument. @@ -87,7 +99,7 @@ private void BuildServices() _memberRepositoryMock.Object, Mock.Of(), _authorizationServiceMock.Object, - Mock.Of(), + departmentsServiceMock.Object, Mock.Of(), Mock.Of(), _unitsServiceMock.Object, @@ -101,8 +113,8 @@ private void BuildServices() _memberRepositoryMock.Object, Mock.Of(), Mock.Of(), - Mock.Of(), - Mock.Of(), + _channelPermissionServiceMock.Object, + departmentsServiceMock.Object, Mock.Of(), _unitsServiceMock.Object, _userProfileServiceMock.Object, @@ -162,6 +174,18 @@ protected ChatChannel BuildCommanderLine() [TestFixture] public class when_provisioning_a_commander_line : with_the_commander_line { + [Test] + public void requester_without_incident_assignment_should_be_rejected_before_channel_lookup() + { + GivenCommanderIs(TestData.Users.TestUser2Id); + _channelPermissionServiceMock.Setup(x => x.CanAccessIncidentAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null)).ReturnsAsync(false); + + Func act = async () => await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null); + + act.Should().ThrowAsync(); + _channelRepositoryMock.Verify(x => x.GetByDmKeyAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task no_established_command_should_not_provision_a_line() { @@ -341,11 +365,18 @@ public async Task an_outgoing_commander_should_lose_access_on_the_next_check() public async Task the_requester_should_keep_access_across_a_handover() { GivenCommanderIs(TestData.Users.TestUser3Id); + _callsServiceMock.Setup(x => x.GetCallByIdAsync(CallId, It.IsAny())).ReturnsAsync(new Call + { + CallId = CallId, + DepartmentId = DepartmentId, + Dispatches = new List { new CallDispatch { CallId = CallId, UserId = TestData.Users.TestUser1Id } } + }); _memberRepositoryMock .Setup(x => x.GetUserMemberAsync("commander-line-1", TestData.Users.TestUser1Id)) .ReturnsAsync(new ChatChannelMember { ChatChannelId = "commander-line-1", + DepartmentId = DepartmentId, UserId = TestData.Users.TestUser1Id, ParticipantType = (int)ChatParticipantType.User }); @@ -355,6 +386,38 @@ public async Task the_requester_should_keep_access_across_a_handover() result.Should().BeTrue(); } + [Test] + public async Task a_released_requester_should_lose_access_even_when_the_member_row_remains() + { + GivenCommanderIs(TestData.Users.TestUser2Id); + _memberRepositoryMock + .Setup(x => x.GetUserMemberAsync("commander-line-1", TestData.Users.TestUser1Id)) + .ReturnsAsync(new ChatChannelMember + { + ChatChannelId = "commander-line-1", + DepartmentId = DepartmentId, + UserId = TestData.Users.TestUser1Id, + ParticipantType = (int)ChatParticipantType.User + }); + _memberRepositoryMock.Setup(x => x.GetByChannelIdAsync("commander-line-1")).ReturnsAsync(new List + { + new ChatChannelMember + { + ChatChannelId = "commander-line-1", + DepartmentId = DepartmentId, + UserId = TestData.Users.TestUser1Id, + ParticipantType = (int)ChatParticipantType.User + } + }); + _incidentCommandServiceMock.Setup(x => x.GetAssignmentsForCallAsync(DepartmentId, CallId)).ReturnsAsync(new List()); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser1Id, null); + var audience = await _chatPermissionService.ResolveChannelAudienceUserIdsAsync(BuildCommanderLine()); + + result.Should().BeFalse(); + audience.Should().NotContain(TestData.Users.TestUser1Id); + } + [Test] public async Task an_uninvolved_user_should_be_denied() { @@ -394,11 +457,18 @@ public async Task a_department_admin_should_not_get_in_on_admin_standing_alone() public async Task the_audience_should_be_the_requester_plus_the_current_commander_only() { GivenCommanderIs(TestData.Users.TestUser2Id); + _callsServiceMock.Setup(x => x.GetCallByIdAsync(CallId, It.IsAny())).ReturnsAsync(new Call + { + CallId = CallId, + DepartmentId = DepartmentId, + Dispatches = new List { new CallDispatch { CallId = CallId, UserId = TestData.Users.TestUser1Id } } + }); _memberRepositoryMock.Setup(x => x.GetByChannelIdAsync("commander-line-1")).ReturnsAsync(new List { new ChatChannelMember { ChatChannelId = "commander-line-1", + DepartmentId = DepartmentId, UserId = TestData.Users.TestUser1Id, ParticipantType = (int)ChatParticipantType.User } diff --git a/Tests/Resgrid.Tests/Services/ChatFrozenChannelTests.cs b/Tests/Resgrid.Tests/Services/ChatFrozenChannelTests.cs index 5ddcd93f2..427f51f36 100644 --- a/Tests/Resgrid.Tests/Services/ChatFrozenChannelTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatFrozenChannelTests.cs @@ -29,6 +29,7 @@ public class ChatFrozenChannelTests private Mock _messageRepository; private Mock _reactionRepository; private Mock _editRepository; + private Mock _permissionService; private ChatMessage _message; [SetUp] @@ -47,6 +48,9 @@ public void Setup() _messageRepository = new Mock(); _reactionRepository = new Mock(); _editRepository = new Mock(); + _permissionService = new Mock(); + _permissionService.Setup(x => x.CanAccessChannelAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + _permissionService.Setup(x => x.CanModerateChannelAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); _messageRepository.Setup(x => x.GetByIdAsync(MessageId)).ReturnsAsync(_message); _messageRepository @@ -75,7 +79,7 @@ private ChatMessageService BuildService() Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of(), + _permissionService.Object, Mock.Of(), Mock.Of(), Mock.Of()); @@ -85,7 +89,7 @@ public async Task EditMessageAsync_is_refused_once_the_channel_is_frozen() { GivenChannelArchived(true); - var result = await BuildService().EditMessageAsync(MessageId, SenderId, "rewritten after the fact"); + var result = await BuildService().EditMessageAsync(1, MessageId, SenderId, "rewritten after the fact"); result.Should().BeNull(); _message.Body.Should().Be("original body"); @@ -97,7 +101,7 @@ public async Task EditMessageAsync_still_works_while_the_incident_is_active() { GivenChannelArchived(false); - var result = await BuildService().EditMessageAsync(MessageId, SenderId, "corrected"); + var result = await BuildService().EditMessageAsync(1, MessageId, SenderId, "corrected"); result.Should().NotBeNull(); result.Body.Should().Be("corrected"); @@ -108,7 +112,7 @@ public async Task DeleteMessageAsync_refuses_the_author_once_the_channel_is_froz { GivenChannelArchived(true); - var result = await BuildService().DeleteMessageAsync(MessageId, SenderId, asModerator: false, reason: null); + var result = await BuildService().DeleteMessageAsync(1, 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); @@ -121,7 +125,7 @@ public async Task DeleteMessageAsync_still_lets_a_moderator_remove_flagged_conte // 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"); + var result = await BuildService().DeleteMessageAsync(1, MessageId, "moderator", asModerator: true, reason: "policy"); result.Should().BeTrue(); _message.IsModerated.Should().BeTrue(); @@ -134,8 +138,8 @@ 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(); + (await service.AddReactionAsync(1, MessageId, SenderId, null, "👍")).Should().BeFalse(); + (await service.RemoveReactionAsync(1, MessageId, SenderId, null, "👍")).Should().BeFalse(); _reactionRepository.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _reactionRepository.Verify( @@ -148,7 +152,7 @@ public async Task A_missing_channel_reads_as_frozen_so_an_unanchored_edit_cannot { _channelRepository.Setup(x => x.GetByIdAsync(ChannelId)).ReturnsAsync((ChatChannel)null); - var result = await BuildService().EditMessageAsync(MessageId, SenderId, "rewritten"); + var result = await BuildService().EditMessageAsync(1, MessageId, SenderId, "rewritten"); result.Should().BeNull(); } diff --git a/Tests/Resgrid.Tests/Services/ChatMessageServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatMessageServiceTests.cs index f82b9919f..e917b7ac5 100644 --- a/Tests/Resgrid.Tests/Services/ChatMessageServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatMessageServiceTests.cs @@ -34,6 +34,7 @@ public async Task DeleteMessageAsync_should_use_one_effective_actor_classificati var channelRepository = new Mock(); var messageRepository = new Mock(); var editRepository = new Mock(); + var permissionService = new Mock(); var eventAggregator = new Mock(); ChatEventRaised deleteEvent = null; @@ -42,6 +43,8 @@ public async Task DeleteMessageAsync_should_use_one_effective_actor_classificati .Setup(x => x.TombstoneAsync(message.ChatMessageId, It.IsAny(), deletingUserId, expectedModerated, It.IsAny())) .ReturnsAsync(true); channelRepository.Setup(x => x.GetByIdAsync(channel.ChatChannelId)).ReturnsAsync(channel); + permissionService.Setup(x => x.CanAccessChannelAsync(channel, deletingUserId, null)).ReturnsAsync(true); + permissionService.Setup(x => x.CanModerateChannelAsync(channel, deletingUserId)).ReturnsAsync(true); eventAggregator .Setup(x => x.SendMessage(It.IsAny())) .Callback(raised => deleteEvent = raised); @@ -56,12 +59,12 @@ public async Task DeleteMessageAsync_should_use_one_effective_actor_classificati Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of(), + permissionService.Object, Mock.Of(), Mock.Of(), eventAggregator.Object); - var result = await service.DeleteMessageAsync(message.ChatMessageId, deletingUserId, true, null); + var result = await service.DeleteMessageAsync(message.DepartmentId, message.ChatMessageId, deletingUserId, true, null); result.Should().BeTrue(); message.IsModerated.Should().Be(expectedModerated); @@ -93,9 +96,11 @@ public async Task AddReactionAsync_is_idempotent_for_duplicate_reactions(string var channelRepository = new Mock(); var messageRepository = new Mock(); var reactionRepository = new Mock(); + var permissionService = new Mock(); messageRepository.Setup(x => x.GetByIdAsync(message.ChatMessageId)).ReturnsAsync(message); channelRepository.Setup(x => x.GetByIdAsync(channel.ChatChannelId)).ReturnsAsync(channel); + permissionService.Setup(x => x.CanAccessChannelAsync(channel, "user-1", null)).ReturnsAsync(true); reactionRepository .Setup(x => x.GetByMessageIdsAsync(It.IsAny>())) .ReturnsAsync(new[] @@ -122,19 +127,104 @@ public async Task AddReactionAsync_is_idempotent_for_duplicate_reactions(string Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of(), + permissionService.Object, Mock.Of(), Mock.Of(), Mock.Of()); // Case-insensitive user match: stored UserId is "USER-1", caller sends "user-1". - var result = await service.AddReactionAsync(message.ChatMessageId, "user-1", null, emoji); + var result = await service.AddReactionAsync(message.DepartmentId, message.ChatMessageId, "user-1", null, emoji); result.Should().Be(expectedResult); reactionRepository.Verify( x => x.InsertAsync(It.IsAny(), It.IsAny(), false), expectInsert ? Times.Once() : Times.Never()); } + + [Test] + public async Task SendMessageAsync_should_not_trust_the_department_in_the_client_request() + { + var channel = new ChatChannel { ChatChannelId = "channel-2", DepartmentId = 2 }; + var channelRepository = new Mock(); + var permissionService = new Mock(); + channelRepository.Setup(x => x.GetByIdAsync(channel.ChatChannelId)).ReturnsAsync(channel); + + var service = new ChatMessageService( + channelRepository.Object, Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + permissionService.Object, Mock.Of(), Mock.Of(), Mock.Of()); + + var result = await service.SendMessageAsync(1, "user-1", new ChatMessageSendRequest + { + ChatChannelId = channel.ChatChannelId, + DepartmentId = 2, + Body = "forged cross-tenant send", + MessageType = ChatMessageType.Text + }); + + result.Should().BeNull(); + permissionService.Verify(x => x.CanPostAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task EditMessageAsync_should_reject_an_owned_message_outside_the_authenticated_department() + { + var message = new ChatMessage + { + ChatMessageId = "message-2", + ChatChannelId = "channel-2", + DepartmentId = 2, + SenderUserId = "user-1", + Body = "department two" + }; + var messageRepository = new Mock(); + var permissionService = new Mock(); + messageRepository.Setup(x => x.GetByIdAsync(message.ChatMessageId)).ReturnsAsync(message); + + var service = new ChatMessageService( + Mock.Of(), messageRepository.Object, Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + permissionService.Object, Mock.Of(), Mock.Of(), Mock.Of()); + + var result = await service.EditMessageAsync(1, message.ChatMessageId, message.SenderUserId, "forged edit"); + + result.Should().BeNull(); + messageRepository.Verify(x => x.UpdateBodyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + permissionService.Verify(x => x.CanAccessChannelAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task DeleteMessageAsync_should_revalidate_requested_moderator_authority() + { + var message = new ChatMessage + { + ChatMessageId = "message-1", + ChatChannelId = "channel-1", + DepartmentId = 1, + SenderUserId = "sender" + }; + var channel = new ChatChannel { ChatChannelId = message.ChatChannelId, DepartmentId = 1 }; + var channelRepository = new Mock(); + var messageRepository = new Mock(); + var permissionService = new Mock(); + messageRepository.Setup(x => x.GetByIdAsync(message.ChatMessageId)).ReturnsAsync(message); + channelRepository.Setup(x => x.GetByIdAsync(channel.ChatChannelId)).ReturnsAsync(channel); + permissionService.Setup(x => x.CanModerateChannelAsync(channel, "not-a-moderator")).ReturnsAsync(false); + + var service = new ChatMessageService( + channelRepository.Object, messageRepository.Object, Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + permissionService.Object, Mock.Of(), Mock.Of(), Mock.Of()); + + var result = await service.DeleteMessageAsync(1, message.ChatMessageId, "not-a-moderator", true, "forged role"); + + result.Should().BeFalse(); + messageRepository.Verify(x => x.TombstoneAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + /// /// Metadata url validation is a pure static helper on the service, so it is exercised directly /// rather than through the full SendMessageAsync dependency graph. diff --git a/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs index 5e496b511..7884d891d 100644 --- a/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs @@ -63,6 +63,17 @@ 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); + _authorizationServiceMock.Setup(x => x.IsUserValidWithinLimitsAsync(It.IsAny(), 1)).ReturnsAsync(true); + _departmentsServiceMock.Setup(x => x.IsUserInDepartmentAsync(1, It.IsAny())).ReturnsAsync(true); + _departmentsServiceMock + .Setup(x => x.GetMemberUserIdsInDepartmentAsync(1, It.IsAny>())) + .ReturnsAsync((int _, IEnumerable ids) => ids == null + ? new HashSet() + : new HashSet(ids, StringComparer.OrdinalIgnoreCase)); + _departmentGroupsServiceMock.Setup(x => x.GetGroupByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int groupId, bool _) => new DepartmentGroup { DepartmentGroupId = groupId, DepartmentId = 1 }); + _callsServiceMock.Setup(x => x.GetCallByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int callId, bool _) => new Call { CallId = callId, DepartmentId = 1 }); // Default: nobody is authorized for dispatch unless a test opts in. _dispatchAccessServiceMock.Setup(x => x.CanUseDispatchAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); @@ -110,6 +121,42 @@ protected static ChatChannelMember CreateUserMember(ChatChannel channel, string [TestFixture] public class when_evaluating_channel_access : with_the_chat_permission_service { + [Test] + public async Task inactive_department_member_should_not_have_access() + { + var channel = CreateChannel(ChatChannelType.DepartmentDefault); + _authorizationServiceMock.Setup(x => x.IsUserValidWithinLimitsAsync(TestData.Users.TestUser1Id, 1)).ReturnsAsync(false); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task cached_access_should_not_outlive_active_department_membership() + { + var channel = CreateChannel(ChatChannelType.DepartmentDefault); + _authorizationServiceMock.Setup(x => x.IsUserValidWithinLimitsAsync(TestData.Users.TestUser1Id, 1)).ReturnsAsync(false); + _cacheProviderMock.Setup(x => x.GetStringAsync(It.Is(key => key.Contains(":access:")))).ReturnsAsync("1"); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task cached_allow_should_not_outlive_channel_membership() + { + var channel = CreateChannel(ChatChannelType.DirectMessage); + _cacheProviderMock.Setup(x => x.GetStringAsync(It.Is(key => key.Contains(":access:")))).ReturnsAsync("1"); + _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync(channel.ChatChannelId, TestData.Users.TestUser1Id)) + .ReturnsAsync((ChatChannelMember)null); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + } + [Test] public async Task chatbot_owner_should_have_access() { @@ -181,6 +228,32 @@ public async Task dm_non_member_should_not_have_access() result.Should().BeFalse(); } + [Test] + public async Task dm_member_from_another_department_should_not_have_access() + { + var channel = CreateChannel(ChatChannelType.DirectMessage); + var member = CreateUserMember(channel, TestData.Users.TestUser1Id); + member.DepartmentId = 2; + _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync(channel.ChatChannelId, TestData.Users.TestUser1Id)).ReturnsAsync(member); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task user_outside_the_channel_department_should_not_have_access_even_with_a_member_row() + { + var channel = CreateChannel(ChatChannelType.DirectMessage); + _departmentsServiceMock.Setup(x => x.IsUserInDepartmentAsync(1, TestData.Users.TestUser1Id)).ReturnsAsync(false); + _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync(channel.ChatChannelId, TestData.Users.TestUser1Id)) + .ReturnsAsync(CreateUserMember(channel, TestData.Users.TestUser1Id)); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + } + [Test] public async Task dm_unit_member_should_have_access_when_the_user_crews_the_unit() { @@ -261,7 +334,7 @@ public async Task group_default_group_member_should_have_access() channel.GroupId = 9; _departmentGroupsServiceMock.Setup(x => x.GetAllMembersForGroupAsync(9)).ReturnsAsync(new List { - new DepartmentGroupMember { DepartmentGroupId = 9, UserId = TestData.Users.TestUser1Id } + new DepartmentGroupMember { DepartmentGroupId = 9, DepartmentId = 1, UserId = TestData.Users.TestUser1Id } }); var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); @@ -276,7 +349,7 @@ public async Task group_default_non_member_should_not_have_access() channel.GroupId = 9; _departmentGroupsServiceMock.Setup(x => x.GetAllMembersForGroupAsync(9)).ReturnsAsync(new List { - new DepartmentGroupMember { DepartmentGroupId = 9, UserId = TestData.Users.TestUser2Id } + new DepartmentGroupMember { DepartmentGroupId = 9, DepartmentId = 1, UserId = TestData.Users.TestUser2Id } }); var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); @@ -297,13 +370,57 @@ public async Task group_default_department_admin_should_have_access() result.Should().BeTrue(); } + [Test] + public async Task group_default_dispatcher_should_have_access() + { + var channel = CreateChannel(ChatChannelType.GroupDefault); + channel.GroupId = 9; + _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 group_default_from_another_department_should_not_be_accessible() + { + var channel = CreateChannel(ChatChannelType.GroupDefault); + channel.GroupId = 9; + _departmentGroupsServiceMock.Setup(x => x.GetGroupByIdAsync(9, It.IsAny())).ReturnsAsync(new DepartmentGroup + { + DepartmentGroupId = 9, + DepartmentId = 2 + }); + _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 group_member_row_from_another_department_should_not_grant_access() + { + var channel = CreateChannel(ChatChannelType.GroupDefault); + channel.GroupId = 9; + _departmentGroupsServiceMock.Setup(x => x.GetAllMembersForGroupAsync(9)).ReturnsAsync(new List + { + new DepartmentGroupMember { DepartmentGroupId = 9, DepartmentId = 2, UserId = TestData.Users.TestUser1Id } + }); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + } + [Test] public async Task custom_locked_matching_user_rule_should_have_access() { var channel = CreateChannel(ChatChannelType.CustomLocked); _chatChannelAccessRuleRepositoryMock.Setup(x => x.GetByChannelIdAsync(channel.ChatChannelId)).ReturnsAsync(new List { - new ChatChannelAccessRule { RuleType = (int)ChatAccessRuleType.User, UserId = TestData.Users.TestUser1Id } + new ChatChannelAccessRule { DepartmentId = 1, RuleType = (int)ChatAccessRuleType.User, UserId = TestData.Users.TestUser1Id } }); var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); @@ -317,7 +434,7 @@ public async Task custom_locked_matching_role_rule_should_have_access() var channel = CreateChannel(ChatChannelType.CustomLocked); _chatChannelAccessRuleRepositoryMock.Setup(x => x.GetByChannelIdAsync(channel.ChatChannelId)).ReturnsAsync(new List { - new ChatChannelAccessRule { RuleType = (int)ChatAccessRuleType.Role, PersonnelRoleId = 5 } + new ChatChannelAccessRule { DepartmentId = 1, RuleType = (int)ChatAccessRuleType.Role, PersonnelRoleId = 5 } }); _personnelRolesServiceMock.Setup(x => x.GetRolesForUserAsync(TestData.Users.TestUser1Id, 1)).ReturnsAsync(new List { @@ -335,11 +452,11 @@ public async Task custom_locked_matching_group_rule_should_have_access() var channel = CreateChannel(ChatChannelType.CustomLocked); _chatChannelAccessRuleRepositoryMock.Setup(x => x.GetByChannelIdAsync(channel.ChatChannelId)).ReturnsAsync(new List { - new ChatChannelAccessRule { RuleType = (int)ChatAccessRuleType.GroupMembership, GroupId = 9 } + new ChatChannelAccessRule { DepartmentId = 1, RuleType = (int)ChatAccessRuleType.GroupMembership, GroupId = 9 } }); _departmentGroupsServiceMock.Setup(x => x.GetAllMembersForGroupAsync(9)).ReturnsAsync(new List { - new DepartmentGroupMember { DepartmentGroupId = 9, UserId = TestData.Users.TestUser1Id } + new DepartmentGroupMember { DepartmentGroupId = 9, DepartmentId = 1, UserId = TestData.Users.TestUser1Id } }); var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); @@ -354,9 +471,9 @@ public async Task custom_locked_unmatched_user_should_not_have_access() _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync(channel.ChatChannelId, TestData.Users.TestUser1Id)).ReturnsAsync((ChatChannelMember)null); _chatChannelAccessRuleRepositoryMock.Setup(x => x.GetByChannelIdAsync(channel.ChatChannelId)).ReturnsAsync(new List { - new ChatChannelAccessRule { RuleType = (int)ChatAccessRuleType.User, UserId = TestData.Users.TestUser2Id }, - new ChatChannelAccessRule { RuleType = (int)ChatAccessRuleType.Role, PersonnelRoleId = 5 }, - new ChatChannelAccessRule { RuleType = (int)ChatAccessRuleType.GroupMembership, GroupId = 9 } + new ChatChannelAccessRule { DepartmentId = 1, RuleType = (int)ChatAccessRuleType.User, UserId = TestData.Users.TestUser2Id }, + new ChatChannelAccessRule { DepartmentId = 1, RuleType = (int)ChatAccessRuleType.Role, PersonnelRoleId = 5 }, + new ChatChannelAccessRule { DepartmentId = 1, RuleType = (int)ChatAccessRuleType.GroupMembership, GroupId = 9 } }); _personnelRolesServiceMock.Setup(x => x.GetRolesForUserAsync(TestData.Users.TestUser1Id, 1)).ReturnsAsync(new List { @@ -364,7 +481,7 @@ public async Task custom_locked_unmatched_user_should_not_have_access() }); _departmentGroupsServiceMock.Setup(x => x.GetAllMembersForGroupAsync(9)).ReturnsAsync(new List { - new DepartmentGroupMember { DepartmentGroupId = 9, UserId = TestData.Users.TestUser2Id } + new DepartmentGroupMember { DepartmentGroupId = 9, DepartmentId = 1, UserId = TestData.Users.TestUser2Id } }); var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); @@ -376,6 +493,61 @@ public async Task custom_locked_unmatched_user_should_not_have_access() [TestFixture] public class when_evaluating_incident_channel_access : with_the_chat_permission_service { + [Test] + public async Task department_dispatch_standing_alone_should_not_count_as_incident_assignment() + { + _dispatchAccessServiceMock.Setup(x => x.CanUseDispatchAsync(1, TestData.Users.TestUser1Id)).ReturnsAsync(true); + _incidentCommandServiceMock.Setup(x => x.GetAssignmentsForCallAsync(1, 42)).ReturnsAsync(new List()); + + var result = await _chatPermissionService.CanAccessIncidentAsync(1, 42, TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task cached_access_should_not_outlive_incident_assignment() + { + var channel = CreateChannel(ChatChannelType.Incident); + channel.CallId = 42; + _cacheProviderMock.Setup(x => x.GetStringAsync(It.Is(key => key.Contains(":access:")))).ReturnsAsync("1"); + _incidentCommandServiceMock.Setup(x => x.GetAssignmentsForCallAsync(1, 42)).ReturnsAsync(new List()); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + _cacheProviderMock.Verify(x => x.GetStringAsync(It.Is(key => key.Contains(":access:"))), Times.Never); + } + + [Test] + public async Task incident_from_another_department_should_be_denied_before_dispatch_override() + { + var channel = CreateChannel(ChatChannelType.Incident); + channel.CallId = 42; + _callsServiceMock.Setup(x => x.GetCallByIdAsync(42, It.IsAny())).ReturnsAsync(new Call + { + CallId = 42, + DepartmentId = 2 + }); + _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 department_admin_not_assigned_to_the_incident_should_be_denied() + { + var channel = CreateChannel(ChatChannelType.Incident); + channel.CallId = 42; + _authorizationServiceMock.Setup(x => x.CanUserModifyDepartmentAsync(TestData.Users.TestUser1Id, 1)).ReturnsAsync(true); + _incidentCommandServiceMock.Setup(x => x.GetAssignmentsForCallAsync(1, 42)).ReturnsAsync(new List()); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + } + [Test] public async Task dispatched_user_should_have_access_to_incident_channel() { @@ -475,6 +647,34 @@ public async Task unrelated_user_should_not_have_access_to_incident_channel() result.Should().BeFalse(); } + [Test] + public async Task foreign_department_group_dispatch_should_not_grant_access_to_incident_channel() + { + var channel = CreateChannel(ChatChannelType.Incident); + channel.CallId = 42; + _callsServiceMock.Setup(x => x.GetCallByIdAsync(42, It.IsAny())).ReturnsAsync(new Call + { + CallId = 42, + DepartmentId = 1, + GroupDispatches = new List + { + new CallDispatchGroup { CallId = 42, DepartmentGroupId = 77 } + } + }); + _departmentGroupsServiceMock.Setup(x => x.GetGroupByIdAsync(77, It.IsAny())) + .ReturnsAsync(new DepartmentGroup { DepartmentGroupId = 77, DepartmentId = 2 }); + _departmentGroupsServiceMock.Setup(x => x.GetAllMembersForGroupAsync(77)).ReturnsAsync(new List + { + new DepartmentGroupMember { DepartmentGroupId = 77, DepartmentId = 2, UserId = TestData.Users.TestUser1Id } + }); + _incidentCommandServiceMock.Setup(x => x.GetAssignmentsForCallAsync(1, 42)).ReturnsAsync(new List()); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + _departmentGroupsServiceMock.Verify(x => x.GetAllMembersForGroupAsync(77), Times.Never); + } + [Test] public async Task lane_assigned_personnel_should_have_access_to_lane_channel() { @@ -489,6 +689,8 @@ public async Task lane_assigned_personnel_should_have_access_to_lane_channel() { new ResourceAssignment { + DepartmentId = 1, + CallId = 42, CommandStructureNodeId = "node-1", ResourceKind = (int)ResourceAssignmentKind.RealPersonnel, ResourceId = TestData.Users.TestUser1Id @@ -500,6 +702,63 @@ public async Task lane_assigned_personnel_should_have_access_to_lane_channel() result.Should().BeTrue(); } + [Test] + public async Task banned_dispatched_user_should_not_have_access_to_incident_channel() + { + var channel = CreateChannel(ChatChannelType.Incident); + channel.CallId = 42; + _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 } } + }); + _chatChannelMemberRepositoryMock.Setup(x => x.GetUserMemberAsync(channel.ChatChannelId, TestData.Users.TestUser1Id)) + .ReturnsAsync(new ChatChannelMember + { + ChatChannelId = channel.ChatChannelId, + DepartmentId = 1, + UserId = TestData.Users.TestUser1Id, + IsBanned = true + }); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task lane_assigned_unit_should_not_grant_access_to_a_user_who_does_not_crew_it() + { + var channel = CreateChannel(ChatChannelType.IncidentLane); + channel.CallId = 42; + channel.CommandStructureNodeId = "node-1"; + _incidentCommandServiceMock.Setup(x => x.GetNodesForCallAsync(1, 42)).ReturnsAsync(new List + { + new CommandStructureNode { CommandStructureNodeId = "node-1", DepartmentId = 1, CallId = 42 } + }); + _incidentCommandServiceMock.Setup(x => x.GetAssignmentsForCallAsync(1, 42)).ReturnsAsync(new List + { + new ResourceAssignment + { + DepartmentId = 1, + CallId = 42, + CommandStructureNodeId = "node-1", + ResourceKind = (int)ResourceAssignmentKind.RealUnit, + ResourceId = "7" + } + }); + _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 1 }); + _unitsServiceMock.Setup(x => x.GetActiveRolesForUnitAsync(7)).ReturnsAsync(new List + { + new UnitActiveRole { UnitId = 7, UserId = TestData.Users.TestUser2Id } + }); + + var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, 7); + + result.Should().BeFalse(); + } + [Test] public async Task lane_supervisor_should_have_access_to_lane_channel() { @@ -538,6 +797,8 @@ public async Task dispatched_user_without_lane_assignment_should_not_have_access { new ResourceAssignment { + DepartmentId = 1, + CallId = 42, CommandStructureNodeId = "node-1", ResourceKind = (int)ResourceAssignmentKind.RealPersonnel, ResourceId = TestData.Users.TestUser2Id @@ -555,6 +816,10 @@ public async Task command_staff_should_have_access_to_lane_channel() var channel = CreateChannel(ChatChannelType.IncidentLane); channel.CallId = 42; channel.CommandStructureNodeId = "node-1"; + _incidentCommandServiceMock.Setup(x => x.GetNodesForCallAsync(1, 42)).ReturnsAsync(new List + { + new CommandStructureNode { CommandStructureNodeId = "node-1", DepartmentId = 1, CallId = 42 } + }); _incidentCommandServiceMock.Setup(x => x.GetIncidentRolesAsync(1, 42)).ReturnsAsync(new List { new IncidentRoleAssignment { CallId = 42, UserId = TestData.Users.TestUser1Id } @@ -606,10 +871,8 @@ public async Task active_role_holder_should_have_access_to_command_channel() } [Test] - public async Task an_authorized_dispatcher_should_not_have_access_to_command_channel() + public async Task an_authorized_dispatcher_should_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); @@ -618,7 +881,7 @@ public async Task an_authorized_dispatcher_should_not_have_access_to_command_cha var result = await _chatPermissionService.CanAccessChannelAsync(channel, TestData.Users.TestUser1Id, null); - result.Should().BeFalse(); + result.Should().BeTrue(); } [Test] @@ -767,7 +1030,7 @@ public async Task group_admin_should_moderate_group_default_channel() channel.GroupId = 9; _departmentGroupsServiceMock.Setup(x => x.GetAllMembersForGroupAsync(9)).ReturnsAsync(new List { - new DepartmentGroupMember { DepartmentGroupId = 9, UserId = TestData.Users.TestUser1Id, IsAdmin = true } + new DepartmentGroupMember { DepartmentGroupId = 9, DepartmentId = 1, UserId = TestData.Users.TestUser1Id, IsAdmin = true } }); var result = await _chatPermissionService.CanModerateChannelAsync(channel, TestData.Users.TestUser1Id); @@ -782,7 +1045,7 @@ public async Task regular_group_member_should_not_moderate_group_default_channel channel.GroupId = 9; _departmentGroupsServiceMock.Setup(x => x.GetAllMembersForGroupAsync(9)).ReturnsAsync(new List { - new DepartmentGroupMember { DepartmentGroupId = 9, UserId = TestData.Users.TestUser1Id, IsAdmin = false } + new DepartmentGroupMember { DepartmentGroupId = 9, DepartmentId = 1, UserId = TestData.Users.TestUser1Id, IsAdmin = false } }); var result = await _chatPermissionService.CanModerateChannelAsync(channel, TestData.Users.TestUser1Id); @@ -951,6 +1214,7 @@ public async Task dm_with_unit_member_should_expand_to_unit_crew() JoinedOn = DateTime.UtcNow }; _chatChannelMemberRepositoryMock.Setup(x => x.GetByChannelIdAsync(channel.ChatChannelId)).ReturnsAsync(new List { userMember, unitMember }); + _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 1, Name = "Engine 6" }); _unitsServiceMock.Setup(x => x.GetActiveRolesForUnitAsync(7)).ReturnsAsync(new List { new UnitActiveRole { UnitId = 7, UserId = TestData.Users.TestUser2Id }, @@ -962,6 +1226,20 @@ public async Task dm_with_unit_member_should_expand_to_unit_crew() audience.Should().BeEquivalentTo(new[] { TestData.Users.TestUser1Id, TestData.Users.TestUser2Id, TestData.Users.TestUser3Id }); } + [Test] + public async Task member_rows_from_another_department_should_not_enter_the_audience() + { + var channel = CreateChannel(ChatChannelType.AdHocGroup); + var member = CreateUserMember(channel, TestData.Users.TestUser1Id); + member.DepartmentId = 2; + _chatChannelMemberRepositoryMock.Setup(x => x.GetByChannelIdAsync(channel.ChatChannelId)) + .ReturnsAsync(new List { member }); + + var audience = await _chatPermissionService.ResolveChannelAudienceUserIdsAsync(channel); + + audience.Should().BeEmpty(); + } + [Test] public async Task department_default_should_only_include_active_non_deleted_members() { @@ -1233,6 +1511,7 @@ public class when_evaluating_the_unit_dispatch_channel : with_the_chat_permissio { private ChatChannel BuildUnitDispatchChannel() { + _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 1, Name = "Engine 6" }); var channel = CreateChannel(ChatChannelType.UnitDispatch); channel.Name = "Engine 6 Dispatch"; channel.DmKey = "unitdispatch:7"; diff --git a/Tests/Resgrid.Tests/Services/DispatchAccessServiceTests.cs b/Tests/Resgrid.Tests/Services/DispatchAccessServiceTests.cs index c34960550..ebbe7f299 100644 --- a/Tests/Resgrid.Tests/Services/DispatchAccessServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DispatchAccessServiceTests.cs @@ -83,6 +83,19 @@ public async Task the_configured_permission_decides_when_one_exists() result.Should().BeFalse(); } + [Test] + public async Task cached_allow_should_not_outlive_dispatch_permission() + { + var permission = new Permission { DepartmentId = DepartmentId, Action = (int)PermissionActions.DepartmentAdminsOnly }; + GivenPermission(permission); + _cacheProvider.Setup(x => x.GetStringAsync(It.IsAny())).ReturnsAsync("1"); + _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() { diff --git a/Tests/Resgrid.Tests/Services/PushServiceModernApplicationSoundTests.cs b/Tests/Resgrid.Tests/Services/PushServiceModernApplicationSoundTests.cs index f2deb7575..2807c78aa 100644 --- a/Tests/Resgrid.Tests/Services/PushServiceModernApplicationSoundTests.cs +++ b/Tests/Resgrid.Tests/Services/PushServiceModernApplicationSoundTests.cs @@ -63,7 +63,8 @@ public void SetUp() new Mock().Object, new Mock().Object, _novuProvider.Object, - _departmentSettingsService.Object); + _departmentSettingsService.Object, + new Mock().Object); } [TearDown] diff --git a/Tests/Resgrid.Tests/Services/PushServiceNotificationEventCodeTests.cs b/Tests/Resgrid.Tests/Services/PushServiceNotificationEventCodeTests.cs index 41d78b2be..821fac5ea 100644 --- a/Tests/Resgrid.Tests/Services/PushServiceNotificationEventCodeTests.cs +++ b/Tests/Resgrid.Tests/Services/PushServiceNotificationEventCodeTests.cs @@ -1,4 +1,4 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using FluentAssertions; using Moq; using NUnit.Framework; @@ -60,7 +60,8 @@ public void SetUp() new Mock().Object, new Mock().Object, _novuProvider.Object, - _departmentSettingsService.Object); + _departmentSettingsService.Object, + new Mock().Object); } [TearDown] diff --git a/Tests/Resgrid.Tests/Web/Services/ChatAuthorizationTests.cs b/Tests/Resgrid.Tests/Web/Services/ChatAuthorizationTests.cs new file mode 100644 index 000000000..1f553d4f7 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/ChatAuthorizationTests.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Controllers.v4; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + public class ChatAuthorizationTests + { + [TestCase(true, "user-1", "1", true, true)] + [TestCase(false, "user-1", "1", true, false)] + [TestCase(true, null, "1", true, false)] + [TestCase(true, "", "1", true, false)] + [TestCase(true, "user-1", null, true, false)] + [TestCase(true, "user-1", "0", true, false)] + [TestCase(true, "user-1", "not-a-department", true, false)] + [TestCase(true, "user-1", "1", false, false)] + public async Task chat_policy_requires_authenticated_principal_and_all_required_claims( + bool isAuthenticated, string userId, string departmentId, bool hasMessagesView, bool expected) + { + var claims = new List(); + if (userId != null) + claims.Add(new Claim(ClaimTypes.PrimarySid, userId)); + if (departmentId != null) + claims.Add(new Claim(ClaimTypes.PrimaryGroupSid, departmentId)); + if (hasMessagesView) + claims.Add(new Claim(ResgridClaimTypes.Resources.Messages, ResgridClaimTypes.Actions.View)); + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, isAuthenticated ? "Test" : null)); + var policy = new AuthorizationPolicyBuilder().RequireChatAccessClaims().Build(); + var services = new ServiceCollection().AddLogging().AddAuthorization().BuildServiceProvider(); + var authorizationService = services.GetRequiredService(); + + var result = await authorizationService.AuthorizeAsync(principal, null, policy); + + result.Succeeded.Should().Be(expected); + } + + [Test] + public void chat_controller_requires_messages_view_claim() + { + var authorize = typeof(ChatController).GetCustomAttributes() + .Single(x => x.Policy == ResgridResources.Messages_View); + + authorize.Policy.Should().Be(ResgridResources.Messages_View); + } + + [Test] + public void chat_moderation_controller_requires_messages_view_claim() + { + var authorize = typeof(ChatModerationController).GetCustomAttributes() + .Single(x => x.Policy == ResgridResources.Messages_View); + + authorize.Policy.Should().Be(ResgridResources.Messages_View); + } + + [TestCase(nameof(ChatbotController.GetChatChannel), ResgridResources.Messages_View)] + [TestCase(nameof(ChatbotController.SendChatMessage), ResgridResources.Messages_View)] + [TestCase(nameof(ChatbotController.SendChatMessage), ResgridResources.Messages_Create)] + [TestCase(nameof(ChatbotController.NewChatSession), ResgridResources.Messages_View)] + [TestCase(nameof(ChatbotController.NewChatSession), ResgridResources.Messages_Create)] + [TestCase(nameof(ChatbotController.AskIncident), ResgridResources.Messages_View)] + [TestCase(nameof(ChatbotController.AskIncident), ResgridResources.Messages_Create)] + [TestCase(nameof(ChatbotController.IncidentSuggestions), ResgridResources.Messages_View)] + public void chatbot_chat_actions_require_message_claims(string methodName, string policy) + { + var method = typeof(ChatbotController).GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public); + + method.Should().NotBeNull(); + method.GetCustomAttributes().Should().ContainSingle(x => x.Policy == policy); + } + + [TestCase(nameof(ChatController.CreateDirectMessage), ResgridResources.Messages_Create)] + [TestCase(nameof(ChatController.CreateIncidentCommanderLine), ResgridResources.Messages_Create)] + [TestCase(nameof(ChatController.CreateAdHocChannel), ResgridResources.Messages_Create)] + [TestCase(nameof(ChatController.CreateCustomChannel), ResgridResources.Messages_Create)] + [TestCase(nameof(ChatController.SendMessage), ResgridResources.Messages_Create)] + [TestCase(nameof(ChatController.AddReaction), ResgridResources.Messages_Create)] + [TestCase(nameof(ChatController.UploadAttachment), ResgridResources.Messages_Create)] + [TestCase(nameof(ChatController.FlagMessage), ResgridResources.Messages_Create)] + [TestCase(nameof(ChatController.UpdateChannel), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatController.AddMembers), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatController.RemoveMember), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatController.SetNotificationPreference), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatController.EditMessage), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatController.RemoveReaction), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatController.Ack), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatController.MarkRead), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatController.PinMessage), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatController.UnpinMessage), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatController.ArchiveChannel), ResgridResources.Messages_Delete)] + [TestCase(nameof(ChatController.DeleteMessage), ResgridResources.Messages_Delete)] + public void mutating_chat_actions_require_the_corresponding_message_claim(string methodName, string policy) + { + var method = typeof(ChatController).GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public); + + method.Should().NotBeNull(); + method.GetCustomAttributes().Should().ContainSingle(x => x.Policy == policy); + } + + [TestCase(nameof(ChatModerationController.ResolveFlag), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatModerationController.MuteUser), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatModerationController.BanUser), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatModerationController.LockChannel), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatModerationController.UpdateSettings), ResgridResources.Messages_Update)] + [TestCase(nameof(ChatModerationController.DeleteMessage), ResgridResources.Messages_Delete)] + public void mutating_chat_moderation_actions_require_the_corresponding_message_claim(string methodName, string policy) + { + var method = typeof(ChatModerationController).GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public); + + method.Should().NotBeNull(); + method.GetCustomAttributes().Should().ContainSingle(x => x.Policy == policy); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs b/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs index e519a0909..9abba02f8 100644 --- a/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs @@ -48,6 +48,8 @@ public void SetUp() featureToggleService .Setup(x => x.IsEnabledAsync(FeatureFlagKeys.ChatSystem, DepartmentId, It.IsAny(), It.IsAny>())) .ReturnsAsync(true); + var authorizationService = new Mock(); + authorizationService.Setup(x => x.IsUserValidWithinLimitsAsync(UserId, DepartmentId)).ReturnsAsync(true); _chatPermissionService.Setup(x => x.CanSendAsUnitAsync(UserId, UnitId, DepartmentId)).ReturnsAsync(true); @@ -101,7 +103,7 @@ public void SetUp() Mock.Of(), Mock.Of(), featureToggleService.Object, - Mock.Of(), + authorizationService.Object, Mock.Of(), Mock.Of(), Mock.Of(), diff --git a/Tests/Resgrid.Tests/Web/User/SecurityControllerTests.cs b/Tests/Resgrid.Tests/Web/User/SecurityControllerTests.cs new file mode 100644 index 000000000..1a4e99816 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/SecurityControllerTests.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Identity; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Areas.User.Models.Security; + +namespace Resgrid.Tests.Web.User +{ + [TestFixture] + [NonParallelizable] + public class SecurityControllerTests + { + private const int DepartmentId = 10; + private const string UserId = "audit-admin"; + private Mock _departmentsService; + private Mock _auditService; + private SecurityController _controller; + + [SetUp] + public void SetUp() + { + _departmentsService = new Mock(); + _auditService = new Mock(); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()), + new Claim( + ResgridClaimTypes.Resources.Department, + ResgridClaimTypes.Actions.Update) + }, "test")) + }; + Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = + new HttpContextAccessor { HttpContext = httpContext }; + + _controller = new SecurityController( + _departmentsService.Object, + _auditService.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + null, + null, + Mock.Of(), + Mock.Of()) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [TearDown] + public void TearDown() + { + Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = null; + } + + [Test] + public async Task GetAuditLogsList_ProvidesChronologicalSortKeyAndDecisionColumns() + { + var older = new AuditLog + { + AuditLogId = 1, + DepartmentId = DepartmentId, + UserId = "actor-1", + LoggedOn = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc), + Successful = false, + LogType = (int)AuditLogTypes.UserRemoved, + Message = "Older" + }; + var newer = new AuditLog + { + AuditLogId = 2, + DepartmentId = DepartmentId, + UserId = "actor-1", + LoggedOn = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Successful = true, + LogType = (int)AuditLogTypes.UserAdded, + Message = "Newer" + }; + var unknownTime = new AuditLog + { + AuditLogId = 3, + DepartmentId = DepartmentId, + LogType = (int)AuditLogTypes.PermissionsChanged, + Message = "Unknown time" + }; + + _auditService.Setup(x => x.GetAllAuditLogsForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List { older, newer, unknownTime }); + _auditService.Setup(x => x.GetAuditLogTypeString(It.IsAny())) + .Returns((AuditLogTypes type) => type.ToString()); + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, false)) + .ReturnsAsync(new Department + { + DepartmentId = DepartmentId, + TimeZone = "UTC", + Use24HourTime = true + }); + _departmentsService.Setup(x => x.GetAllPersonnelNamesForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List + { + new PersonName { UserId = "actor-1", FirstName = "Alex", LastName = "Morgan" } + }); + _departmentsService.Setup(x => x.GetAllUsersForDepartmentAsync(DepartmentId, true, false)) + .ReturnsAsync(new List + { + new IdentityUser + { + UserId = "actor-1", + UserName = "alex.morgan", + Email = "alex.morgan@example.com" + } + }); + + var result = await _controller.GetAuditLogsList(); + + var entries = result.Should().BeOfType().Subject.Value + .Should().BeAssignableTo>().Subject.ToList(); + entries.OrderByDescending(x => x.TimestampSort ?? -1).Select(x => x.AuditLogId) + .Should().Equal(2, 1, 3); + entries.Single(x => x.AuditLogId == 2).Name.Should().Be("Alex Morgan"); + entries.Single(x => x.AuditLogId == 2).Successful.Should().BeTrue(); + entries.Single(x => x.AuditLogId == 2).SearchTerms.Should().ContainAll( + "Alex Morgan", + "actor-1", + "alex.morgan", + "alex.morgan@example.com", + "2", + "2026-01-01 00:00:00", + AuditLogTypes.UserAdded.ToString()); + entries.Single(x => x.AuditLogId == 3).TimestampSort.Should().BeNull(); + } + + [Test] + public async Task ViewAudit_ReturnsCompleteAuditEntryAndFriendlyTypeName() + { + var auditLog = new AuditLog + { + AuditLogId = 42, + DepartmentId = DepartmentId, + UserId = "actor-1", + LoggedOn = DateTime.UtcNow, + Successful = true, + LogType = (int)AuditLogTypes.UserAdded, + Message = "User added", + Data = "{\"userId\":\"new-user\"}", + IpAddress = "192.0.2.10", + ServerName = "web-1", + ObjectId = "new-user", + ObjectDepartmentId = DepartmentId, + UserAgent = "Test Agent" + }; + _auditService.Setup(x => x.GetAuditLogByIdAsync(42)).ReturnsAsync(auditLog); + _auditService.Setup(x => x.GetAuditLogTypeString(AuditLogTypes.UserAdded)) + .Returns("User Added"); + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, false)) + .ReturnsAsync(new Department { DepartmentId = DepartmentId }); + + var result = await _controller.ViewAudit(42); + + var model = result.Should().BeOfType().Subject.Model + .Should().BeOfType().Subject; + model.AuditLog.Should().BeSameAs(auditLog); + model.Type.Should().Be(AuditLogTypes.UserAdded); + model.TypeName.Should().Be("User Added"); + } + + [Test] + public async Task ViewAudit_MissingEntry_ReturnsNotFound() + { + _auditService.Setup(x => x.GetAuditLogByIdAsync(404)).ReturnsAsync((AuditLog)null); + + var result = await _controller.ViewAudit(404); + + result.Should().BeOfType(); + _departmentsService.Verify( + x => x.GetDepartmentByIdAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + } +} diff --git a/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs b/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs index b67e30f58..8d4f35b28 100644 --- a/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs +++ b/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs @@ -3,21 +3,24 @@ using System.Security.Claims; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using Resgrid.Config; using Resgrid.Model; using Resgrid.Model.Services; +using Resgrid.Providers.Claims; namespace Resgrid.Web.Eventing.Hubs { /// /// Realtime chat hub. Carries only ephemeral traffic (channel group membership, typing, presence, /// read/delivered pointers) — message writes go through the REST API and fan back out via the - /// RabbitMQ eventing topic and this host's Worker. Group naming: chat:{channelId} per channel, + /// RabbitMQ eventing topic and this host's Worker. Group naming: chat:{channelId}:{accessVersion} + /// per channel (the version rotates on authorization changes), /// chatuser:{deptId}:{userId} for personal events, chatdept:{deptId} for channel-list updates. /// - [Authorize(AuthenticationSchemes = OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] + [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme, Policy = ResgridResources.Messages_View)] public class ChatHub : Hub { private readonly IChatChannelService _chatChannelService; @@ -70,13 +73,18 @@ public override async Task OnConnectedAsync() var departmentId = GetDepartmentId(); var userId = GetUserId(); - if (departmentId > 0 && !string.IsNullOrWhiteSpace(userId)) + if (departmentId > 0 && !string.IsNullOrWhiteSpace(userId) && + await _chatPermissionService.IsActiveDepartmentUserAsync(departmentId, userId)) { Context.Items[DepartmentIdContextKey] = departmentId; Context.Items[UserIdContextKey] = userId; AddUserConnection(userId, Context.ConnectionId); } + else + { + Context.Abort(); + } await base.OnConnectedAsync(); } @@ -150,7 +158,8 @@ public async Task Connect() var departmentId = GetDepartmentId(); var userId = GetUserId(); - if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId)) + if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId) || + !await _chatPermissionService.IsActiveDepartmentUserAsync(departmentId, userId)) return; await Groups.AddToGroupAsync(Context.ConnectionId, $"chatuser:{departmentId}:{userId.ToLowerInvariant()}"); @@ -165,16 +174,21 @@ public async Task Connect() public async Task JoinChannel(string channelId, int? asUnitId = null) { - await ResolveAccessibleChannelOrThrowAsync(channelId, asUnitId); + var channel = await ResolveAccessibleChannelOrThrowAsync(channelId, asUnitId); + var groupName = await GetCurrentChannelGroupNameAsync(channel.ChatChannelId); + if (groupName == null) + throw new HubException("Chat authorization is temporarily unavailable."); - await Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{channelId}"); + await Groups.AddToGroupAsync(Context.ConnectionId, groupName); await Clients.Caller.SendAsync("onChatChannelJoined", channelId); } public async Task LeaveChannel(string channelId) { - await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"chat:{channelId}"); + var groupName = await GetCurrentChannelGroupNameAsync(channelId); + if (groupName != null) + await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); } public async Task Typing(string channelId, string displayName = null, bool isTyping = true, int? asUnitId = null) @@ -199,7 +213,11 @@ public async Task Typing(string channelId, string displayName = null, bool isTyp LastTypingTimestamps[throttleKey] = now; } - await Clients.OthersInGroup($"chat:{channelId}").SendAsync("chatTyping", new + var groupName = await GetCurrentChannelGroupNameAsync(channelId); + if (groupName == null) + return; + + await Clients.OthersInGroup(groupName).SendAsync("chatTyping", new { ChannelId = channelId, UserId = userId, @@ -285,13 +303,32 @@ private async Task ResolveAccessibleChannelOrThrowAsync(string chan return access.Value.Channel; } + public static string BuildChannelGroupName(string channelId, string accessVersion) + { + return string.IsNullOrWhiteSpace(channelId) || string.IsNullOrWhiteSpace(accessVersion) + ? null + : $"chat:{channelId}:{accessVersion}"; + } + + private async Task GetCurrentChannelGroupNameAsync(string channelId) + { + return BuildChannelGroupName(channelId, + await _chatPermissionService.GetChannelAccessVersionAsync(channelId)); + } + public async Task Heartbeat() { var departmentId = GetDepartmentId(); var userId = GetUserId(); - if (departmentId > 0 && !string.IsNullOrWhiteSpace(userId)) - await _chatPresenceService.TouchAsync(departmentId, userId); + if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId) || + !await _chatPermissionService.IsActiveDepartmentUserAsync(departmentId, userId)) + { + Context.Abort(); + return; + } + + await _chatPresenceService.TouchAsync(departmentId, userId); } /// diff --git a/Web/Resgrid.Web.Eventing/Startup.cs b/Web/Resgrid.Web.Eventing/Startup.cs index f46f5546e..a3db335e1 100644 --- a/Web/Resgrid.Web.Eventing/Startup.cs +++ b/Web/Resgrid.Web.Eventing/Startup.cs @@ -247,7 +247,7 @@ public void ConfigureServices(IServiceCollection services) //IssuerSigningKey = signingKey, // Validate the JWT Issuer (iss) claim - ValidateIssuer = false, + ValidateIssuer = true, ValidIssuer = JwtConfig.Issuer, // Validate the JWT Audience (aud) claim @@ -311,6 +311,13 @@ public void ConfigureServices(IServiceCollection services) }; }); + services.AddAuthorization(options => + { + options.AddPolicy(ResgridResources.Messages_View, policy => policy + .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireChatAccessClaims()); + }); + //services.AddHostedService(); } @@ -397,7 +404,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) endpoints.MapHub("/eventingHub"); endpoints.MapHub("/geolocationHub"); - endpoints.MapHub("/chatHub"); + endpoints.MapHub("/chatHub", options => options.CloseOnAuthenticationExpiration = true); }); } diff --git a/Web/Resgrid.Web.Eventing/Worker.cs b/Web/Resgrid.Web.Eventing/Worker.cs index e62d9fc2f..a7209b4c0 100644 --- a/Web/Resgrid.Web.Eventing/Worker.cs +++ b/Web/Resgrid.Web.Eventing/Worker.cs @@ -11,6 +11,8 @@ using Resgrid.Providers.Bus.Rabbit; using Resgrid.Web.Eventing.Hubs.Models; using Resgrid.Model.Events; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; using Microsoft.AspNetCore.SignalR; using Resgrid.Web.Eventing.Hubs; @@ -171,10 +173,20 @@ public async Task IncidentCommandUpdated(int departmentId, string id) { Console.WriteLine($"Processing RabbitMQ IncidentCommandUpdated Event For {departmentId}"); + // Resource releases, lane moves, lead changes and command transfers all change who may + // receive incident chat. Rotate every call-scoped SignalR group before notifying clients; + // old connections remain in an obsolete group that receives no future message payloads. + if (int.TryParse(id, out var callId)) + await InvalidateIncidentChatAccessAsync(departmentId, callId); + var group = _eventingHub.Clients.Group(departmentId.ToString()); if (group != null) await group.SendAsync("incidentCommandUpdated", id); + + await _chatHub.Clients.Group($"chatdept:{departmentId}").SendAsync( + ChatEventKinds.ChannelUpdated, + Newtonsoft.Json.JsonConvert.SerializeObject(new { DepartmentId = departmentId, CallId = id, AuthorizationChanged = true })); } public async Task DepartmentUpdated(int departmentId) @@ -252,7 +264,8 @@ public async Task ChatEventReceived(int departmentId, string payloadJson) return; var chatEvent = Newtonsoft.Json.JsonConvert.DeserializeObject(payloadJson); - if (chatEvent == null || string.IsNullOrWhiteSpace(chatEvent.Kind)) + if (chatEvent == null || string.IsNullOrWhiteSpace(chatEvent.Kind) || departmentId <= 0 || + chatEvent.DepartmentId != departmentId) return; if (chatEvent.Kind == ChatEventKinds.AccessRevoked) @@ -268,7 +281,8 @@ await _chatHub.Clients.Group($"chatuser:{chatEvent.DepartmentId}:{chatEvent.Targ return; } - if (chatEvent.Kind == ChatEventKinds.ChannelUpdated || chatEvent.Kind == ChatEventKinds.ChannelProvisioned) + if (chatEvent.Kind == ChatEventKinds.ChannelUpdated || chatEvent.Kind == ChatEventKinds.ChannelProvisioned || + chatEvent.Kind == ChatEventKinds.ModerationApplied) { var hint = Newtonsoft.Json.JsonConvert.SerializeObject(new { @@ -282,8 +296,10 @@ await _chatHub.Clients.Group($"chatdept:{chatEvent.DepartmentId}") if (!string.IsNullOrWhiteSpace(chatEvent.ChatChannelId)) { - await _chatHub.Clients.Group($"chat:{chatEvent.ChatChannelId}") - .SendAsync(chatEvent.Kind, GuardChatPayloadSize(chatEvent)); + var channelGroupName = await GetCurrentChannelGroupNameAsync(chatEvent.ChatChannelId); + if (channelGroupName != null) + await _chatHub.Clients.Group(channelGroupName) + .SendAsync(chatEvent.Kind, GuardChatPayloadSize(chatEvent)); } return; @@ -291,8 +307,10 @@ await _chatHub.Clients.Group($"chat:{chatEvent.ChatChannelId}") if (!string.IsNullOrWhiteSpace(chatEvent.ChatChannelId)) { - await _chatHub.Clients.Group($"chat:{chatEvent.ChatChannelId}") - .SendAsync(chatEvent.Kind, GuardChatPayloadSize(chatEvent)); + var channelGroupName = await GetCurrentChannelGroupNameAsync(chatEvent.ChatChannelId); + if (channelGroupName != null) + await _chatHub.Clients.Group(channelGroupName) + .SendAsync(chatEvent.Kind, GuardChatPayloadSize(chatEvent)); } } catch (Exception ex) @@ -326,13 +344,14 @@ private async Task ChatAccessRevokedReceived(ChatEventRaised chatEvent) if (string.IsNullOrWhiteSpace(userId)) return; - if (!string.IsNullOrWhiteSpace(channelId) && ChatHub.UserConnections.TryGetValue(userId, out var connections)) + var channelGroupName = await GetCurrentChannelGroupNameAsync(channelId); + if (channelGroupName != null && ChatHub.UserConnections.TryGetValue(userId, out var connections)) { foreach (var connectionId in connections.Keys) { try { - await _chatHub.Groups.RemoveFromGroupAsync(connectionId, $"chat:{channelId}"); + await _chatHub.Groups.RemoveFromGroupAsync(connectionId, channelGroupName); } catch (Exception ex) { @@ -345,6 +364,37 @@ await _chatHub.Clients.Group($"chatuser:{chatEvent.DepartmentId}:{userId.ToLower .SendAsync(chatEvent.Kind, chatEvent.PayloadJson); } + private async Task GetCurrentChannelGroupNameAsync(string channelId) + { + if (string.IsNullOrWhiteSpace(channelId)) + return null; + + using var scope = _serviceProvider.CreateScope(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + var version = await permissionService.GetChannelAccessVersionAsync(channelId); + return ChatHub.BuildChannelGroupName(channelId, version); + } + + private async Task InvalidateIncidentChatAccessAsync(int departmentId, int callId) + { + if (departmentId <= 0 || callId <= 0) + return; + + using var scope = _serviceProvider.CreateScope(); + var channelRepository = scope.ServiceProvider.GetRequiredService(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + var channels = await channelRepository.GetByCallIdAsync(callId); + + if (channels == null) + return; + + foreach (var channel in channels) + { + if (channel != null && channel.DepartmentId == departmentId) + await permissionService.InvalidateChannelCacheAsync(channel.ChatChannelId); + } + } + /// /// Defensive guard against oversized realtime payloads (SignalR/Redis backplane limits): /// beyond ~64KB the message Body is truncated and flagged; clients fetch the full body via REST. diff --git a/Web/Resgrid.Web.Services/Controllers/ChatbotTelegramController.cs b/Web/Resgrid.Web.Services/Controllers/ChatbotTelegramController.cs index 57d46fb46..d2f7a424e 100644 --- a/Web/Resgrid.Web.Services/Controllers/ChatbotTelegramController.cs +++ b/Web/Resgrid.Web.Services/Controllers/ChatbotTelegramController.cs @@ -48,15 +48,15 @@ public ChatbotTelegramController( public async Task Webhook() { // Verify the secret token Telegram echoes back in the X-Telegram-Bot-Api-Secret-Token - // header (configured via setWebhook). When a secret is configured, requests without a - // matching token are rejected so the webhook cannot be driven by arbitrary callers. + // header (configured via setWebhook). Fail closed when the secret is missing: otherwise an + // arbitrary caller could forge a linked Telegram user id and drive authenticated chatbot work. var configuredSecret = ChatbotConfig.TelegramWebhookSecretToken; - if (!string.IsNullOrEmpty(configuredSecret)) - { - var providedSecret = Request.Headers["X-Telegram-Bot-Api-Secret-Token"].ToString(); - if (!SecretMatches(providedSecret, configuredSecret)) - return Unauthorized(); - } + if (string.IsNullOrWhiteSpace(configuredSecret)) + return StatusCode(StatusCodes.Status503ServiceUnavailable); + + var providedSecret = Request.Headers["X-Telegram-Bot-Api-Secret-Token"].ToString(); + if (!SecretMatches(providedSecret, configuredSecret)) + return Unauthorized(); try { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index 5fe6eecc6..9441ee6c8 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Resgrid.Config; @@ -15,6 +16,7 @@ using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; +using Resgrid.Providers.Claims; using Resgrid.Web.Services.Helpers; using Resgrid.Web.Services.Models.v4; using Resgrid.Web.Services.Models.v4.Chat; @@ -28,6 +30,7 @@ namespace Resgrid.Web.Services.Controllers.v4 [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] + [Authorize(Policy = ResgridResources.Messages_View)] public class ChatController : V4AuthenticatedApiControllerbase { #region Members and Constructors @@ -219,6 +222,7 @@ public async Task> GetChannel(string channelI /// Target user or unit for the direct message /// ChatChannelCreatedResult with the existing or newly created channel [HttpPost("CreateDirectMessage")] + [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status403Forbidden)] @@ -279,6 +283,7 @@ public async Task> CreateDirectMessage([F /// The call to reach command on, and optionally the unit to speak as /// ChatChannelCreatedResult with the existing or newly created commander line [HttpPost("CreateIncidentCommanderLine")] + [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status403Forbidden)] @@ -342,6 +347,7 @@ public async Task> CreateIncidentCommande /// Name and initial members of the channel /// ChatChannelCreatedResult with the newly created channel [HttpPost("CreateAdHocChannel")] + [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -356,13 +362,20 @@ public async Task> CreateAdHocChannel([Fr if (input == null || input.MemberUserIds == null || input.MemberUserIds.Count <= 0) return BadRequest(); - // No name supplied: name the group after its members (Slack-style), capped to the column length. + // The service derives an unnamed group only after it validates every supplied member belongs to + // this department, so profile lookups cannot be driven across the tenant boundary. var name = input.Name?.Trim(); - if (String.IsNullOrWhiteSpace(name)) - name = await BuildGroupNameFromMembersAsync(input.MemberUserIds); var result = new ChatChannelCreatedResult(); - var channel = await _chatChannelService.CreateAdHocGroupChannelAsync(DepartmentId, UserId, name, input.MemberUserIds, cancellationToken); + ChatChannel channel; + try + { + channel = await _chatChannelService.CreateAdHocGroupChannelAsync(DepartmentId, UserId, name, input.MemberUserIds, cancellationToken); + } + catch (UnauthorizedAccessException) + { + return StatusCode(StatusCodes.Status403Forbidden); + } if (channel != null) { @@ -386,6 +399,7 @@ public async Task> CreateAdHocChannel([Fr /// Name, topic and OR-evaluated access rules for the channel /// ChatChannelCreatedResult with the newly created channel [HttpPost("CreateCustomChannel")] + [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -420,7 +434,15 @@ public async Task> CreateCustomChannel([F } var result = new ChatChannelCreatedResult(); - var channel = await _chatChannelService.CreateCustomChannelAsync(DepartmentId, UserId, input.Name, input.Topic, rules, cancellationToken); + ChatChannel channel; + try + { + channel = await _chatChannelService.CreateCustomChannelAsync(DepartmentId, UserId, input.Name, input.Topic, rules, cancellationToken); + } + catch (UnauthorizedAccessException) + { + return StatusCode(StatusCodes.Status403Forbidden); + } if (channel != null) { @@ -445,6 +467,7 @@ public async Task> CreateCustomChannel([F /// New name and topic /// GetChatChannelResult with the updated channel [HttpPut("UpdateChannel")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -464,7 +487,7 @@ public async Task> UpdateChannel(string chann return Unauthorized(); var result = new GetChatChannelResult(); - var updated = await _chatChannelService.UpdateChannelAsync(channelId, input?.Name, input?.Topic, UserId, cancellationToken); + var updated = await _chatChannelService.UpdateChannelAsync(DepartmentId, channelId, input?.Name, input?.Topic, UserId, cancellationToken); if (updated != null) { @@ -488,6 +511,7 @@ public async Task> UpdateChannel(string chann /// Chat channel identifier /// ChatActionResult indicating whether the channel was archived [HttpDelete("ArchiveChannel")] + [Authorize(Policy = ResgridResources.Messages_Delete)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -504,7 +528,7 @@ public async Task> ArchiveChannel(string channelI return Unauthorized(); var result = new ChatActionResult(); - result.Success = await _chatChannelService.SetChannelArchivedAsync(channelId, true, UserId, cancellationToken); + result.Success = await _chatChannelService.SetChannelArchivedAsync(DepartmentId, channelId, true, UserId, cancellationToken); result.Status = result.Success ? ResponseHelper.Success : ResponseHelper.Failure; if (result.Success) @@ -583,6 +607,7 @@ public async Task> GetMembers(string channelI /// UserIds to add /// Array of ChatMemberResultData objects for the added members [HttpPost("AddMembers")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -624,7 +649,7 @@ public async Task> AddMembers(string channelI try { - added = await _chatChannelService.AddMembersAsync(channelId, input.UserIds, UserId, cancellationToken); + added = await _chatChannelService.AddMembersAsync(DepartmentId, channelId, input.UserIds, UserId, cancellationToken); } catch (UnauthorizedAccessException) { @@ -663,6 +688,7 @@ public async Task> AddMembers(string channelI /// UserId of the member to remove /// ChatActionResult indicating whether the member was removed [HttpDelete("RemoveMember")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -684,7 +710,7 @@ public async Task> RemoveMember(string channelId, return Unauthorized(); var result = new ChatActionResult(); - result.Success = await _chatChannelService.RemoveMemberAsync(channelId, userId, UserId, cancellationToken); + result.Success = await _chatChannelService.RemoveMemberAsync(DepartmentId, channelId, userId, UserId, cancellationToken); result.Status = result.Success ? ResponseHelper.Deleted : ResponseHelper.Failure; ResponseHelper.PopulateV4ResponseData(result); @@ -698,6 +724,7 @@ public async Task> RemoveMember(string channelId, /// Notification preference to apply /// ChatActionResult indicating whether the preference was saved [HttpPut("SetNotificationPreference")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -880,6 +907,7 @@ public async Task> GetMessage(string messageI /// Message content and options /// ChatMessageSentResult with the persisted message [HttpPost("SendMessage")] + [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -900,6 +928,9 @@ public async Task> SendMessage(string channe // Assistant conversations are plain text only: no threads, no attachments/GIFs, no urgent // priority. Enforced here so every client (web and mobile) gets the same behavior. var channel = await _chatChannelService.GetChannelByIdAsync(channelId); + if (channel == null || channel.DepartmentId != DepartmentId) + return NotFound(); + var isChatbotChannel = channel != null && channel.ChannelType == (int)ChatChannelType.Chatbot; if (isChatbotChannel && ((ChatMessageType)input.MessageType != ChatMessageType.Text || !String.IsNullOrWhiteSpace(input.ThreadRootMessageId))) return BadRequest("Assistant conversations only support plain text messages."); @@ -944,7 +975,7 @@ public async Task> SendMessage(string channe try { - message = await _chatMessageService.SendMessageAsync(UserId, request, cancellationToken); + message = await _chatMessageService.SendMessageAsync(DepartmentId, UserId, request, cancellationToken); } catch (UnauthorizedAccessException) { @@ -1012,6 +1043,7 @@ public async Task> SendMessage(string channe /// New message body /// GetChatMessageResult with the updated message [HttpPut("EditMessage")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -1026,10 +1058,14 @@ public async Task> EditMessage(string message if (input == null || String.IsNullOrWhiteSpace(input.Body)) return BadRequest(); + var accessCheck = await CheckMessageChannelAccessAsync(messageId); + if (accessCheck != null) + return accessCheck; + if (await IsChatbotMessageChannelAsync(messageId)) return BadRequest("Messages can't be edited in assistant conversations."); - var message = await _chatMessageService.EditMessageAsync(messageId, UserId, input.Body, cancellationToken); + var message = await _chatMessageService.EditMessageAsync(DepartmentId, messageId, UserId, input.Body, cancellationToken); if (message == null) return BadRequest(); @@ -1049,6 +1085,7 @@ public async Task> EditMessage(string message /// Chat message identifier /// ChatActionResult indicating whether the message was deleted [HttpDelete("DeleteMessage")] + [Authorize(Policy = ResgridResources.Messages_Delete)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> DeleteMessage(string messageId, CancellationToken cancellationToken) @@ -1056,11 +1093,15 @@ public async Task> DeleteMessage(string messageId if (!await ChatEnabledAsync()) return NotFound(); + var accessCheck = await CheckMessageChannelAccessAsync(messageId); + if (accessCheck != null) + return accessCheck; + if (await IsChatbotMessageChannelAsync(messageId)) return BadRequest("Messages can't be deleted in assistant conversations."); var result = new ChatActionResult(); - result.Success = await _chatMessageService.DeleteMessageAsync(messageId, UserId, false, null, cancellationToken); + result.Success = await _chatMessageService.DeleteMessageAsync(DepartmentId, messageId, UserId, false, null, cancellationToken); result.Status = result.Success ? ResponseHelper.Deleted : ResponseHelper.Failure; ResponseHelper.PopulateV4ResponseData(result); @@ -1078,6 +1119,7 @@ public async Task> DeleteMessage(string messageId /// Emoji to react with /// ChatActionResult indicating whether the reaction was added [HttpPost("AddReaction")] + [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -1104,7 +1146,7 @@ public async Task> AddReaction(string messageId, return BadRequest("Reactions aren't available in assistant conversations."); var result = new ChatActionResult(); - result.Success = await _chatMessageService.AddReactionAsync(messageId, UserId, null, input.Emoji, cancellationToken); + result.Success = await _chatMessageService.AddReactionAsync(DepartmentId, messageId, UserId, null, input.Emoji, cancellationToken); result.Status = result.Success ? ResponseHelper.Created : ResponseHelper.Failure; ResponseHelper.PopulateV4ResponseData(result); @@ -1118,6 +1160,7 @@ public async Task> AddReaction(string messageId, /// Emoji to remove /// ChatActionResult indicating whether the reaction was removed [HttpDelete("RemoveReaction")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -1138,7 +1181,7 @@ public async Task> RemoveReaction(string messageI return BadRequest("Reactions aren't available in assistant conversations."); var result = new ChatActionResult(); - result.Success = await _chatMessageService.RemoveReactionAsync(messageId, UserId, null, emoji, cancellationToken); + result.Success = await _chatMessageService.RemoveReactionAsync(DepartmentId, messageId, UserId, null, emoji, cancellationToken); result.Status = result.Success ? ResponseHelper.Deleted : ResponseHelper.Failure; ResponseHelper.PopulateV4ResponseData(result); @@ -1151,6 +1194,7 @@ public async Task> RemoveReaction(string messageI /// Chat message identifier /// ChatActionResult; Success is true when a pending acknowledgment was stamped [HttpPost("Ack")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -1164,7 +1208,7 @@ public async Task> Ack(string messageId, Cancella return accessCheck; var result = new ChatActionResult(); - var acknowledged = await _chatMessageService.AcknowledgeMessageAsync(messageId, UserId, cancellationToken); + var acknowledged = await _chatMessageService.AcknowledgeMessageAsync(DepartmentId, messageId, UserId, cancellationToken); result.Success = acknowledged > 0; result.Status = result.Success ? ResponseHelper.Success : ResponseHelper.NotFound; @@ -1192,12 +1236,17 @@ public async Task> GetAcks(string messageId) if (message == null || message.DepartmentId != DepartmentId) return NotFound(); - if (!String.Equals(message.SenderUserId, UserId, StringComparison.OrdinalIgnoreCase)) - { - var channel = await _chatChannelService.GetChannelByIdAsync(message.ChatChannelId); - if (channel == null || !await _chatPermissionService.CanModerateChannelAsync(channel, UserId)) - return Unauthorized(); - } + var channel = await _chatChannelService.GetChannelByIdAsync(message.ChatChannelId); + if (channel == null || channel.DepartmentId != DepartmentId) + return NotFound(); + + var canModerate = await _chatPermissionService.CanModerateChannelAsync(channel, UserId); + var canAccess = await _chatPermissionService.CanAccessChannelAsync(channel, UserId, null); + if (!canAccess && !canModerate) + return Unauthorized(); + + if (!String.Equals(message.SenderUserId, UserId, StringComparison.OrdinalIgnoreCase) && !canModerate) + return Unauthorized(); var result = new GetChatAcksResult(); var acks = await _chatMessageService.GetAcksForMessageAsync(messageId); @@ -1273,6 +1322,7 @@ public async Task> GetMyPendingAcks() /// Sequence read and optional unit identity /// ChatActionResult indicating whether the pointer advanced [HttpPut("MarkRead")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -1309,6 +1359,7 @@ public async Task> MarkRead(string channelId, [Fr /// Chat message identifier /// ChatActionResult indicating whether the message was pinned [HttpPost("PinMessage")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -1323,6 +1374,7 @@ public async Task> PinMessage(string messageId, C /// Chat message identifier /// ChatActionResult indicating whether the message was unpinned [HttpDelete("UnpinMessage")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -1374,6 +1426,7 @@ public async Task> GetPins(string channelId) /// The file being uploaded /// ChatAttachmentUploadedResult with the new attachment identifier [HttpPost("UploadAttachment")] + [Authorize(Policy = ResgridResources.Messages_Create)] [Consumes("multipart/form-data")] [RequestSizeLimit(MaxAttachmentRequestBytes)] [RequestFormLimits(MultipartBodyLengthLimit = MaxAttachmentRequestBytes)] @@ -1419,7 +1472,8 @@ public async Task> UploadAttachment(s return Unauthorized(); var message = await _chatMessageService.GetMessageByIdAsync(messageId); - if (message == null || message.ChatChannelId != channelId || !String.Equals(message.SenderUserId, UserId, StringComparison.OrdinalIgnoreCase)) + if (message == null || message.DepartmentId != DepartmentId || message.ChatChannelId != channelId || + message.DeletedOn.HasValue || !String.Equals(message.SenderUserId, UserId, StringComparison.OrdinalIgnoreCase)) return BadRequest(); byte[] data; @@ -1483,7 +1537,7 @@ public async Task GetAttachment(string attachmentId) return NotFound(); var message = await _chatMessageService.GetMessageByIdAsync(attachment.ChatMessageId); - if (message == null || message.DeletedOn.HasValue) + if (message == null || message.DepartmentId != DepartmentId || message.ChatChannelId != attachment.ChatChannelId || message.DeletedOn.HasValue) return NotFound(); var channel = await _chatChannelService.GetChannelByIdAsync(attachment.ChatChannelId); @@ -1515,7 +1569,7 @@ public async Task GetAttachmentThumbnail(string attachmentId) return NotFound(); var message = await _chatMessageService.GetMessageByIdAsync(attachment.ChatMessageId); - if (message == null || message.DeletedOn.HasValue) + if (message == null || message.DepartmentId != DepartmentId || message.ChatChannelId != attachment.ChatChannelId || message.DeletedOn.HasValue) return NotFound(); var channel = await _chatChannelService.GetChannelByIdAsync(attachment.ChatChannelId); @@ -1657,6 +1711,7 @@ public async Task> GetPresence(string userId /// Reason and optional note for the flag /// ChatActionResult indicating whether the flag was recorded [HttpPost("FlagMessage")] + [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -1692,9 +1747,10 @@ public async Task> FlagMessage(string messageId, #region Private Helpers - private Task ChatEnabledAsync() + private async Task ChatEnabledAsync() { - return _featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, DepartmentId); + return await _authorizationService.IsUserValidWithinLimitsAsync(UserId, DepartmentId) && + await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, DepartmentId); } /// @@ -1744,11 +1800,13 @@ private async Task IsRateLimitedAsync(string action, int limitPerWindow) private async Task IsChatbotMessageChannelAsync(string messageId) { var message = await _chatMessageService.GetMessageByIdAsync(messageId); - if (message == null) + if (message == null || message.DepartmentId != DepartmentId) return false; var channel = await _chatChannelService.GetChannelByIdAsync(message.ChatChannelId); - return channel != null && channel.ChannelType == (int)ChatChannelType.Chatbot; + return channel != null && channel.DepartmentId == DepartmentId && + await _chatPermissionService.CanAccessChannelAsync(channel, UserId, null) && + channel.ChannelType == (int)ChatChannelType.Chatbot; } /// @@ -1788,7 +1846,7 @@ private async Task> SetPinnedAsync(string message return Unauthorized(); var result = new ChatActionResult(); - result.Success = await _chatMessageService.SetMessagePinnedAsync(messageId, UserId, pinned, cancellationToken); + result.Success = await _chatMessageService.SetMessagePinnedAsync(DepartmentId, messageId, UserId, pinned, cancellationToken); result.Status = result.Success ? ResponseHelper.Updated : ResponseHelper.Failure; ResponseHelper.PopulateV4ResponseData(result); @@ -1915,58 +1973,6 @@ private static ChatMessageResultData ConvertMessageResultData(ChatMessage messag return data; } - private const int MaxDerivedGroupNameLength = 100; - - // "Alice Smith, Bob Jones" from the invited members (creator excluded — matches how Slack labels - // unnamed group DMs). Falls back to "New group" only if no profile resolves. - private async Task BuildGroupNameFromMembersAsync(List memberUserIds) - { - var ids = memberUserIds? - .Where(id => !String.IsNullOrWhiteSpace(id) && !String.Equals(id, UserId, StringComparison.OrdinalIgnoreCase)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList() ?? new List(); - - var names = new List(); - if (ids.Count > 0) - { - var profiles = await _userProfileService.GetSelectedUserProfilesAsync(ids); - foreach (var profile in profiles ?? new List()) - { - var name = profile?.FullName?.AsFirstNameLastName; - if (!String.IsNullOrWhiteSpace(name)) - names.Add(name); - } - } - - if (names.Count == 0) - return "New group"; - - names.Sort(StringComparer.OrdinalIgnoreCase); - - var joined = String.Join(", ", names); - if (joined.Length <= MaxDerivedGroupNameLength) - return joined; - - // Too long: keep whole names and count the rest ("Alice Smith, Bob Jones +3"). - var kept = new List(); - var length = 0; - foreach (var name in names) - { - var addition = (kept.Count == 0 ? 0 : 2) + name.Length; - if (length + addition + 6 > MaxDerivedGroupNameLength) - break; - - kept.Add(name); - length += addition; - } - - if (kept.Count == 0) - kept.Add(names[0].Length > MaxDerivedGroupNameLength - 6 ? names[0].Substring(0, MaxDerivedGroupNameLength - 6) : names[0]); - - var remaining = names.Count - kept.Count; - return remaining > 0 ? $"{String.Join(", ", kept)} +{remaining}" : String.Join(", ", kept); - } - // DM channels have no stored Name; label each with the counterpart participant so multiple // DMs stay distinguishable in every client list. Unit counterparts already carry a // DisplayNameOverride stamped at creation; user counterparts resolve via profile lookup. diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatModerationController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatModerationController.cs index bfc5b3342..540c03682 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatModerationController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatModerationController.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -13,6 +14,7 @@ using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Services; +using Resgrid.Providers.Claims; using Resgrid.Web.Services.Helpers; using Resgrid.Web.Services.Models.v4.Chat; using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; @@ -26,6 +28,7 @@ namespace Resgrid.Web.Services.Controllers.v4 [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] + [Authorize(Policy = ResgridResources.Messages_View)] public class ChatModerationController : V4AuthenticatedApiControllerbase { #region Members and Constructors @@ -100,7 +103,7 @@ public async Task> GetFlags(int status = 0, int return Unauthorized(); var result = new GetChatFlagsResult(); - var flags = await _chatModerationService.GetFlagsAsync(DepartmentId, (ChatFlagStatus)status, page, pageSize); + var flags = await _chatModerationService.GetFlagsAsync(DepartmentId, UserId, (ChatFlagStatus)status, page, pageSize); if (flags != null && flags.Any()) { @@ -130,6 +133,7 @@ public async Task> GetFlags(int status = 0, int /// Resolution status and note /// ChatActionResult indicating whether the flag was resolved [HttpPut("ResolveFlag")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -182,6 +186,7 @@ public async Task> ResolveFlag(string flagId, [Fr /// Reason for the deletion /// ChatActionResult indicating whether the message was deleted [HttpDelete("DeleteMessage")] + [Authorize(Policy = ResgridResources.Messages_Delete)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -205,7 +210,7 @@ public async Task> DeleteMessage(string messageId try { - result.Success = await _chatModerationService.ModeratorDeleteMessageAsync(messageId, UserId, reason, cancellationToken, BuildModerationContext("ChannelModerator")); + result.Success = await _chatModerationService.ModeratorDeleteMessageAsync(DepartmentId, messageId, UserId, reason, cancellationToken, BuildModerationContext("ChannelModerator")); } catch (UnauthorizedAccessException) { @@ -229,6 +234,7 @@ public async Task> DeleteMessage(string messageId /// Target user and mute expiration (null MutedUntil = unmute) /// ChatActionResult indicating whether the mute was applied [HttpPost("MuteUser")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -255,7 +261,7 @@ public async Task> MuteUser(string channelId, [Fr try { - result.Success = await _chatModerationService.SetUserMutedAsync(channelId, input.TargetUserId, input.MutedUntil, UserId, null, cancellationToken, BuildModerationContext("ChannelModerator")); + result.Success = await _chatModerationService.SetUserMutedAsync(DepartmentId, channelId, input.TargetUserId, input.MutedUntil, UserId, null, cancellationToken, BuildModerationContext("ChannelModerator")); } catch (UnauthorizedAccessException) { @@ -279,6 +285,7 @@ public async Task> MuteUser(string channelId, [Fr /// Target user and whether they are banned /// ChatActionResult indicating whether the ban was applied [HttpPost("BanUser")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -305,7 +312,7 @@ public async Task> BanUser(string channelId, [Fro try { - result.Success = await _chatModerationService.SetUserBannedAsync(channelId, input.TargetUserId, input.Banned, UserId, null, cancellationToken, BuildModerationContext("ChannelModerator")); + result.Success = await _chatModerationService.SetUserBannedAsync(DepartmentId, channelId, input.TargetUserId, input.Banned, UserId, null, cancellationToken, BuildModerationContext("ChannelModerator")); } catch (UnauthorizedAccessException) { @@ -329,6 +336,7 @@ public async Task> BanUser(string channelId, [Fro /// Whether to lock and the reason /// ChatActionResult indicating whether the lock state was changed [HttpPost("LockChannel")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -355,7 +363,7 @@ public async Task> LockChannel(string channelId, try { - result.Success = await _chatModerationService.SetChannelLockedAsync(channelId, input.Locked, UserId, input.Reason, cancellationToken, BuildModerationContext("ChannelModerator")); + result.Success = await _chatModerationService.SetChannelLockedAsync(DepartmentId, channelId, input.Locked, UserId, input.Reason, cancellationToken, BuildModerationContext("ChannelModerator")); } catch (UnauthorizedAccessException) { @@ -393,7 +401,7 @@ public async Task> GetActions(strin return Unauthorized(); var result = new GetChatModerationActionsResult(); - var actions = await _chatModerationService.GetModerationActionsAsync(DepartmentId, channelId, page, pageSize); + var actions = await _chatModerationService.GetModerationActionsAsync(DepartmentId, UserId, channelId, page, pageSize); if (actions != null && actions.Any()) { @@ -461,6 +469,7 @@ public async Task> GetSettings() /// New settings values /// GetChatSettingsResult with the saved settings [HttpPut("UpdateSettings")] + [Authorize(Policy = ResgridResources.Messages_Update)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -492,7 +501,7 @@ public async Task> UpdateSettings([FromBody] settings.ChatbotEnabled = input.ChatbotEnabled; var result = new GetChatSettingsResult(); - var saved = await _chatChannelService.SaveDepartmentSettingsAsync(settings, cancellationToken); + var saved = await _chatChannelService.SaveDepartmentSettingsAsync(DepartmentId, UserId, settings, cancellationToken); if (saved != null) { @@ -705,7 +714,7 @@ public async Task> GetExports() return Unauthorized(); var result = new GetChatExportsResult(); - var exports = await _chatModerationService.GetExportsAsync(DepartmentId); + var exports = await _chatModerationService.GetExportsAsync(DepartmentId, UserId); if (exports != null && exports.Any()) { @@ -775,9 +784,10 @@ public async Task DownloadExport(string exportId, CancellationTok #region Private Helpers - private Task ChatEnabledAsync() + private async Task ChatEnabledAsync() { - return _featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, DepartmentId); + return await _authorizationService.IsUserValidWithinLimitsAsync(UserId, DepartmentId) && + await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, DepartmentId); } /// diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs index 5cfd92496..12f247ed2 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs @@ -12,6 +12,7 @@ using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Services; +using Resgrid.Providers.Claims; using Resgrid.Web.Services.Helpers; using Resgrid.Web.Services.Models.v4.Chat; using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; @@ -327,6 +328,7 @@ public async Task UpdateConfig([FromBody] ChatbotConfigRequest re /// Gets (creating if needed) the caller's chatbot conversation channel. /// [HttpGet("GetChatChannel")] + [Authorize(Policy = ResgridResources.Messages_View)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetChatChannel() { @@ -360,6 +362,8 @@ public async Task> GetChatChannel() /// SignalR (chatMessageReceived). Idempotent via clientMessageId. /// [HttpPost("SendChatMessage")] + [Authorize(Policy = ResgridResources.Messages_View)] + [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> SendChatMessage([FromBody] ChatbotChatMessageRequest request) { @@ -375,7 +379,7 @@ public async Task> SendChatMessage([FromB if (channel == null) return NotFound(); - var message = await _chatMessageService.SendMessageAsync(UserId, new ChatMessageSendRequest + var message = await _chatMessageService.SendMessageAsync(DepartmentId, UserId, new ChatMessageSendRequest { ChatChannelId = channel.ChatChannelId, DepartmentId = DepartmentId, @@ -445,6 +449,8 @@ private static ChatbotMessageSentResult BuildMessageSentResult(ChatMessage messa /// Resets the chatbot conversational session (context/pending intents). Message history remains. /// [HttpPost("NewChatSession")] + [Authorize(Policy = ResgridResources.Messages_View)] + [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> NewChatSession() { @@ -453,17 +459,19 @@ public async Task> NewChatSession() try { + var channel = await _chatChannelService.EnsureChatbotChannelAsync(DepartmentId, UserId); + if (channel == null) + return NotFound(); + var session = await _chatbotSessionManager.GetOrCreateSessionAsync(UserId, DepartmentId, Resgrid.Chatbot.Models.ChatbotPlatform.WebChat, UserId); if (session != null) await _chatbotSessionManager.EndSessionAsync(session.SessionId); // Visible confirmation in the conversation (fans out over SignalR to every client); // without it the reset is silent and looks like the button did nothing. - var channel = await _chatChannelService.EnsureChatbotChannelAsync(DepartmentId, UserId); - if (channel != null) - await _chatMessageService.SendBotMessageAsync(channel.ChatChannelId, - DepartmentId.ToString(System.Globalization.CultureInfo.InvariantCulture), - "Starting a new conversation — your previous context has been cleared.", "Resgrid Assistant"); + await _chatMessageService.SendBotMessageAsync(channel.ChatChannelId, + DepartmentId.ToString(System.Globalization.CultureInfo.InvariantCulture), + "Starting a new conversation — your previous context has been cleared.", "Resgrid Assistant"); var result = new ChatbotSessionResetResult { @@ -494,6 +502,8 @@ await _chatMessageService.SendBotMessageAsync(channel.ChatChannelId, /// not a bypass of it. /// [HttpPost("AskIncident")] + [Authorize(Policy = ResgridResources.Messages_View)] + [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> AskIncident([FromBody] AskIncidentAssistantInput input) @@ -550,6 +560,7 @@ public async Task> AskIncident([From /// can build these offline; this endpoint keeps a server-side department in sync with them. /// [HttpGet("IncidentSuggestions")] + [Authorize(Policy = ResgridResources.Messages_View)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> IncidentSuggestions([FromQuery] int callId) { @@ -598,6 +609,9 @@ public async Task> IncidentSugg private async Task ChatbotChatEnabledAsync() { + if (!await _authorizationService.IsUserValidWithinLimitsAsync(UserId, DepartmentId)) + return false; + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, DepartmentId)) return false; diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 24233362e..f9e697a01 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -312,7 +312,7 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy(ResgridResources.UnitLog_Create, policy => policy.RequireClaim(ResgridClaimTypes.Resources.UnitLog, ResgridClaimTypes.Actions.Create)); options.AddPolicy(ResgridResources.UnitLog_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.UnitLog, ResgridClaimTypes.Actions.Delete)); - options.AddPolicy(ResgridResources.Messages_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Messages, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.Messages_View, policy => policy.RequireChatAccessClaims()); options.AddPolicy(ResgridResources.Messages_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Messages, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.Messages_Create, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Messages, ResgridClaimTypes.Actions.Create)); options.AddPolicy(ResgridResources.Messages_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Messages, ResgridClaimTypes.Actions.Delete)); diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts index 39353b7cb..d5f02ca32 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts @@ -80,6 +80,7 @@ class ChatHub { private joinedChannels = new Map(); private heartbeatTimer: ReturnType | null = null; private channelsRefreshHandlers = new Set<() => void>(); + private authorizationRefreshPromise: Promise | null = null; public subscribeChannelsRefresh(handler: () => void): () => void { this.channelsRefreshHandlers.add(handler); @@ -207,13 +208,13 @@ class ChatHub { } }); connection.on(CHAT_HUB_EVENTS.ChannelUpdated, () => { - this.notifyChannelsRefresh(); + void this.refreshChannelAuthorizations(); }); connection.on(CHAT_HUB_EVENTS.ChannelProvisioned, () => { - this.notifyChannelsRefresh(); + void this.refreshChannelAuthorizations(); }); connection.on(CHAT_HUB_EVENTS.ModerationApplied, () => { - this.notifyChannelsRefresh(); + void this.refreshChannelAuthorizations(); }); connection.on(CHAT_HUB_EVENTS.AccessRevoked, (arg: unknown) => { const payload = parsePayload(arg); @@ -282,6 +283,31 @@ class ChatHub { } } + // Channel SignalR groups are authorization-epoch scoped. Membership/rule/moderation and + // incident-board changes rotate the epoch server-side before the refresh hint is broadcast; + // rejoining here performs a fresh server authorization check and moves eligible connections + // into the new group. A forged/stale client that ignores the hint stays in an obsolete group. + private async refreshChannelAuthorizations(): Promise { + if (this.authorizationRefreshPromise) { + return this.authorizationRefreshPromise; + } + + this.authorizationRefreshPromise = (async () => { + if (this.connection && this.connection.state === HubConnectionState.Connected) { + for (const [channelId, asUnitId] of this.joinedChannels.entries()) { + await this.invokeJoin(channelId, asUnitId); + } + } + this.notifyChannelsRefresh(); + })(); + + try { + await this.authorizationRefreshPromise; + } finally { + this.authorizationRefreshPromise = null; + } + } + // Page through missed messages (cap 200/page, max 5 pages) so long outages recover fully. private async deltaSync(channelId: string): Promise { const lastRealSeq = () => diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs index dd1979e1c..412f4368a 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net; using System.Threading; @@ -450,23 +451,62 @@ public async Task Audits() public async Task GetAuditLogsList() { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + var auditLogsJson = new List(); var auditLogs = await _auditService.GetAllAuditLogsForDepartmentAsync(DepartmentId); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + var users = await _departmentsService.GetAllUsersForDepartmentAsync(DepartmentId, true); + var personnelNamesByUserId = personnelNames + .Where(x => x != null && !String.IsNullOrWhiteSpace(x.UserId)) + .GroupBy(x => x.UserId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase); + var usersByUserId = users + .Where(x => x != null && !String.IsNullOrWhiteSpace(x.UserId)) + .GroupBy(x => x.UserId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase); foreach (var auditLog in auditLogs) { var auditJson = new AuditLogJson(); auditJson.AuditLogId = auditLog.AuditLogId; - //auditJson.Name = UserHelper.GetFullNameForUser(null, auditLog.UserId); + personnelNamesByUserId.TryGetValue(auditLog.UserId ?? String.Empty, out var personName); + usersByUserId.TryGetValue(auditLog.UserId ?? String.Empty, out var user); + auditJson.Name = personName != null && !String.IsNullOrWhiteSpace(personName.Name) + ? personName.Name + : (!String.IsNullOrWhiteSpace(auditLog.UserId) ? auditLog.UserId : "System"); auditJson.Message = auditLog.Message; + auditJson.Successful = auditLog.Successful; if (auditLog.LoggedOn.HasValue) + { auditJson.Timestamp = auditLog.LoggedOn.Value.TimeConverterToString(department); + auditJson.TimestampSort = auditLog.LoggedOn.Value.Ticks / TimeSpan.TicksPerMillisecond; + } else auditJson.Timestamp = "Unknown"; auditJson.Type = _auditService.GetAuditLogTypeString((AuditLogTypes)auditLog.LogType); + auditJson.SearchTerms = String.Join(" ", new[] + { + auditJson.Name, + auditLog.UserId, + user?.UserName, + user?.Email, + auditLog.AuditLogId.ToString(CultureInfo.InvariantCulture), + auditLog.DepartmentId.ToString(CultureInfo.InvariantCulture), + auditLog.LogType.ToString(CultureInfo.InvariantCulture), + auditLog.ObjectId, + auditLog.ObjectDepartmentId.ToString(CultureInfo.InvariantCulture), + auditLog.IpAddress, + auditLog.ServerName, + auditJson.Timestamp, + auditLog.LoggedOn?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + auditJson.Type, + ((AuditLogTypes)auditLog.LogType).ToString() + }.Where(x => !String.IsNullOrWhiteSpace(x))); auditLogsJson.Add(auditJson); } @@ -479,14 +519,20 @@ public async Task ViewAudit(int auditLogId) if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) return Unauthorized(); - var model = new ViewAuditLogView(); - model.AuditLog = await _auditService.GetAuditLogByIdAsync(auditLogId); - model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); - model.Type = (AuditLogTypes)model.AuditLog.LogType; + var auditLog = await _auditService.GetAuditLogByIdAsync(auditLogId); + if (auditLog == null) + return NotFound(); - if (model.AuditLog.DepartmentId != DepartmentId) + if (auditLog.DepartmentId != DepartmentId) return Unauthorized(); + var model = new ViewAuditLogView + { + AuditLog = auditLog, + Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId), + Type = (AuditLogTypes)auditLog.LogType, + TypeName = _auditService.GetAuditLogTypeString((AuditLogTypes)auditLog.LogType) + }; return View(model); } diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/AuditLogJson.cs b/Web/Resgrid.Web/Areas/User/Models/Security/AuditLogJson.cs index e4913f498..11cb8df6c 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Security/AuditLogJson.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Security/AuditLogJson.cs @@ -5,7 +5,10 @@ public class AuditLogJson public int AuditLogId { get; set; } public string Type { get; set; } public string Timestamp { get; set; } + public long? TimestampSort { get; set; } public string Name { get; set; } public string Message { get; set; } + public bool Successful { get; set; } + public string SearchTerms { get; set; } } -} \ No newline at end of file +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/ViewAuditLogView.cs b/Web/Resgrid.Web/Areas/User/Models/Security/ViewAuditLogView.cs index ada5855d0..053fd477e 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Security/ViewAuditLogView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Security/ViewAuditLogView.cs @@ -7,5 +7,6 @@ public class ViewAuditLogView public AuditLog AuditLog {get;set;} public Department Department {get;set;} public AuditLogTypes Type {get;set;} + public string TypeName { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/Audits.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Audits.cshtml index 13c2e4754..d5e444f5b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Audits.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Audits.cshtml @@ -32,7 +32,23 @@
-
+
+
+ + +
+
+

+ Search by user name, user or audit ID, email address, date/time, or audit type. + Search and sorting apply within the selected audit type. +

+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/ViewAudit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/ViewAudit.cshtml index 06d820201..d9eb53499 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/ViewAudit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/ViewAudit.cshtml @@ -1,10 +1,30 @@ @model Resgrid.Web.Areas.User.Models.Security.ViewAuditLogView +@functions { + public string DisplayValue(string value) + { + return String.IsNullOrWhiteSpace(value) ? "Not recorded" : value; + } +} @{ ViewBag.Title = "Resgrid | View Audit Log"; + var loggedBy = await UserHelper.GetFullNameForUser(Model.AuditLog.UserId); + if (String.IsNullOrWhiteSpace(loggedBy)) + { + loggedBy = "Not recorded"; + } } @section Styles { - + }
@@ -38,53 +58,142 @@
-
+
+
+
Audit Log ID:
+
@Model.AuditLog.AuditLogId
+
+
+
+
+
Department ID:
+
@Model.AuditLog.DepartmentId
+
+
+
+
+
Audit Type:
@Model.Type.ToString()
+
+
+
Log Type ID:
+
@Model.AuditLog.LogType
+
+
-
+
-
Logged By:
-
@(await UserHelper.GetFullNameForUser(Model.AuditLog.UserId))
+
Type Description:
+
@DisplayValue(Model.TypeName)
+
+
+
+
+
Result:
+
+ @if (Model.AuditLog.Successful) + { + Successful + } + else + { + Failed + } +
-
+
-
Logged On:
- @if (Model.AuditLog.LoggedOn.HasValue) - { -
@Model.AuditLog.LoggedOn.Value.TimeConverterToString(Model.Department)
- } - else - { -
Unknown
- } +
Logged By:
+
@loggedBy
+
+
+
+
+
User ID:
+
@DisplayValue(Model.AuditLog.UserId)
-
+
-
Message:
+
Logged On (Local):
+
@(Model.AuditLog.LoggedOn.HasValue ? Model.AuditLog.LoggedOn.Value.TimeConverterToString(Model.Department) : "Unknown")
+
+
+
+
+
Logged On (UTC):
- @Model.AuditLog.Message + @if (Model.AuditLog.LoggedOn.HasValue) + { + @DateTime.SpecifyKind(Model.AuditLog.LoggedOn.Value, DateTimeKind.Utc).ToString("yyyy-MM-dd HH:mm:ss.fffffff 'UTC'") + } + else + { + @:Unknown + }
+
+
+
+
IP Address:
+
@DisplayValue(Model.AuditLog.IpAddress)
+
+
+
+
+
Server Name:
+
@DisplayValue(Model.AuditLog.ServerName)
+
+
+
+
+
+
+
Object ID:
+
@DisplayValue(Model.AuditLog.ObjectId)
+
+
+
+
+
Object Department ID:
+
@Model.AuditLog.ObjectDepartmentId
+
+
+
+
User Agent:
+
@DisplayValue(Model.AuditLog.UserAgent)
+
+
+
+
+
+
+
Message:
+
@DisplayValue(Model.AuditLog.Message)
+
+
+
+
+
+
Data:
-
- @Model.AuditLog.Data -
+
@DisplayValue(Model.AuditLog.Data)
diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.audits.js b/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.audits.js index 492f6faf2..9b465f77e 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.audits.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.audits.js @@ -7,12 +7,29 @@ var resgrid; $(document).ready(function () { resgrid.common.analytics.track('Security Audits'); + var textRenderer = $.fn.dataTable.render.text(); var table = $("#auditLogsList").DataTable({ ajax: { url: resgrid.absoluteBaseUrl + '/User/Security/GetAuditLogsList', dataSrc: '' }, pageLength: 50, + order: [[1, 'desc']], + language: { + search: 'Search audit logs:', + searchPlaceholder: 'Name, ID, email, date/time, or type' + }, + initComplete: function () { + var api = this.api(); + var typeColumn = api.column('auditType:name'); + var typeFilter = $('#auditLogTypeFilter'); + + typeColumn.data().unique().sort().each(function (type) { + if (type) { + $('
[HttpPost("SendChatMessage")] - [Authorize(Policy = ResgridResources.Messages_View)] + [Authorize(Policy = ResgridResources.Chat_View)] [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> SendChatMessage([FromBody] ChatbotChatMessageRequest request) @@ -449,7 +449,7 @@ private static ChatbotMessageSentResult BuildMessageSentResult(ChatMessage messa /// Resets the chatbot conversational session (context/pending intents). Message history remains. ///
[HttpPost("NewChatSession")] - [Authorize(Policy = ResgridResources.Messages_View)] + [Authorize(Policy = ResgridResources.Chat_View)] [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> NewChatSession() @@ -502,7 +502,7 @@ await _chatMessageService.SendBotMessageAsync(channel.ChatChannelId, /// not a bypass of it. ///
[HttpPost("AskIncident")] - [Authorize(Policy = ResgridResources.Messages_View)] + [Authorize(Policy = ResgridResources.Chat_View)] [Authorize(Policy = ResgridResources.Messages_Create)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -560,7 +560,7 @@ public async Task> AskIncident([From /// can build these offline; this endpoint keeps a server-side department in sync with them. ///
[HttpGet("IncidentSuggestions")] - [Authorize(Policy = ResgridResources.Messages_View)] + [Authorize(Policy = ResgridResources.Chat_View)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> IncidentSuggestions([FromQuery] int callId) { diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index f9e697a01..350ddb222 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -312,7 +312,8 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy(ResgridResources.UnitLog_Create, policy => policy.RequireClaim(ResgridClaimTypes.Resources.UnitLog, ResgridClaimTypes.Actions.Create)); options.AddPolicy(ResgridResources.UnitLog_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.UnitLog, ResgridClaimTypes.Actions.Delete)); - options.AddPolicy(ResgridResources.Messages_View, policy => policy.RequireChatAccessClaims()); + options.AddPolicy(ResgridResources.Messages_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Messages, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.Chat_View, policy => policy.RequireChatAccessClaims()); options.AddPolicy(ResgridResources.Messages_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Messages, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.Messages_Create, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Messages, ResgridClaimTypes.Actions.Create)); options.AddPolicy(ResgridResources.Messages_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Messages, ResgridClaimTypes.Actions.Delete)); diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts index d5f02ca32..673aa39ff 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatHub.ts @@ -207,14 +207,14 @@ class ChatHub { addPendingAck(payload.ChatMessageId); } }); - connection.on(CHAT_HUB_EVENTS.ChannelUpdated, () => { - void this.refreshChannelAuthorizations(); + connection.on(CHAT_HUB_EVENTS.ChannelUpdated, (arg: unknown) => { + this.refreshChannelAuthorizationFromPayload(arg); }); - connection.on(CHAT_HUB_EVENTS.ChannelProvisioned, () => { - void this.refreshChannelAuthorizations(); + connection.on(CHAT_HUB_EVENTS.ChannelProvisioned, (arg: unknown) => { + this.refreshChannelAuthorizationFromPayload(arg); }); - connection.on(CHAT_HUB_EVENTS.ModerationApplied, () => { - void this.refreshChannelAuthorizations(); + connection.on(CHAT_HUB_EVENTS.ModerationApplied, (arg: unknown) => { + this.refreshChannelAuthorizationFromPayload(arg); }); connection.on(CHAT_HUB_EVENTS.AccessRevoked, (arg: unknown) => { const payload = parsePayload(arg); @@ -283,6 +283,25 @@ class ChatHub { } } + private refreshChannelAuthorizationFromPayload(arg: unknown): void { + const source = parsePayload>(arg); + const channelId = source ? pick(source, 'chatChannelId', 'ChatChannelId')?.trim() : undefined; + + if (channelId) { + void this.refreshChannelAuthorization(channelId); + return; + } + + void this.refreshChannelAuthorizations(); + } + + private async refreshChannelAuthorization(channelId: string): Promise { + if (this.joinedChannels.has(channelId)) { + await this.joinChannel(channelId, this.joinedChannels.get(channelId)); + } + this.notifyChannelsRefresh(); + } + // Channel SignalR groups are authorization-epoch scoped. Membership/rule/moderation and // incident-board changes rotate the epoch server-side before the refresh hint is broadcast; // rejoining here performs a fresh server authorization check and moves eligible connections @@ -295,7 +314,17 @@ class ChatHub { this.authorizationRefreshPromise = (async () => { if (this.connection && this.connection.state === HubConnectionState.Connected) { for (const [channelId, asUnitId] of this.joinedChannels.entries()) { - await this.invokeJoin(channelId, asUnitId); + try { + await this.invokeJoin(channelId, asUnitId, true); + } catch (err) { + console.error('invokeJoin failed during authorization refresh', { + op: 'refreshChannelAuthorizations', + channelId, + asUnitId, + err, + }); + throw err; + } } } this.notifyChannelsRefresh(); @@ -347,7 +376,11 @@ class ChatHub { }, HEARTBEAT_INTERVAL_MS); } - private async invokeJoin(channelId: string, asUnitId: number | undefined): Promise { + private async invokeJoin( + channelId: string, + asUnitId: number | undefined, + throwOnError = false, + ): Promise { if (!this.connection || this.connection.state !== HubConnectionState.Connected) { return; } @@ -355,6 +388,9 @@ class ChatHub { await this.connection.invoke(CHAT_HUB_METHODS.JoinChannel, channelId, asUnitId ?? null); } catch (error) { console.error('Chat join channel failed.', error); + if (throwOnError) { + throw error; + } } } From 48763ef4886cedd01b2e6d65b179639a4a609511 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 20 Aug 2026 22:13:57 -0700 Subject: [PATCH 3/3] RG-T133 PR#475 fixes --- .../Resgrid.Model/Providers/ICacheProvider.cs | 2 +- .../AzureRedisCacheProvider.cs | 14 +++++--- .../Framework/ObjectSerializationTests.cs | 9 +++++ .../Services/ChatCommanderLineTests.cs | 9 +++++ .../ChatControllerCommanderLineTests.cs | 36 +++++++++++++++---- .../Controllers/v4/ChatController.cs | 16 ++++----- 6 files changed, 63 insertions(+), 23 deletions(-) diff --git a/Core/Resgrid.Model/Providers/ICacheProvider.cs b/Core/Resgrid.Model/Providers/ICacheProvider.cs index fb3823c68..7da534636 100644 --- a/Core/Resgrid.Model/Providers/ICacheProvider.cs +++ b/Core/Resgrid.Model/Providers/ICacheProvider.cs @@ -6,7 +6,7 @@ namespace Resgrid.Model.Providers public interface ICacheProvider { T Retrieve(string cacheKey, Func fallbackFunction, TimeSpan expiration) where T : class; - Task RetrieveAsync(string cacheKey, Func> fallbackFunction, TimeSpan expiration) where T : class; + Task RetrieveAsync(string cacheKey, Func> fallbackFunction, TimeSpan expiration); void Remove(string cacheKey); Task RemoveAsync(string cacheKey); bool IsConnected(); diff --git a/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs b/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs index d30946510..98626cd45 100644 --- a/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs +++ b/Providers/Resgrid.Providers.Cache/AzureRedisCacheProvider.cs @@ -109,9 +109,9 @@ public void Remove(string cacheKey) public async Task RetrieveAsync(string cacheKey, Func> fallbackFunction, TimeSpan expiration) - where T : class { - T data = null; + T data = default; + var hasCachedData = false; IDatabase cache = null; try @@ -125,16 +125,20 @@ public async Task RetrieveAsync(string cacheKey, Func> fallbackFun try { if (cacheValue.HasValue) + { data = ObjectSerialization.Deserialize(cacheValue); + hasCachedData = data is not null; + } } catch (Exception deserializeEx) { Logging.LogException(deserializeEx); await RemoveAsync(cacheKey); - data = null; + data = default; + hasCachedData = false; } - if (data != null) + if (hasCachedData) return data; } } @@ -154,7 +158,7 @@ public async Task RetrieveAsync(string cacheKey, Func> fallbackFun if (Config.SystemBehaviorConfig.CacheEnabled && _connection != null && _connection.IsConnected) { - if (data != null && cache != null) + if (data is not null && cache != null) { try { diff --git a/Tests/Resgrid.Tests/Framework/ObjectSerializationTests.cs b/Tests/Resgrid.Tests/Framework/ObjectSerializationTests.cs index 3a8140373..135d22528 100644 --- a/Tests/Resgrid.Tests/Framework/ObjectSerializationTests.cs +++ b/Tests/Resgrid.Tests/Framework/ObjectSerializationTests.cs @@ -39,5 +39,14 @@ public void TestDeserialization() testObj.Id.Should().Be(500); testObj.Data.Should().Be("This is just a test object. TestStringToSearchOn"); } + + [TestCase(true)] + [TestCase(false)] + public void BooleanRoundTrips(bool value) + { + var serialized = ObjectSerialization.Serialize(value); + + ObjectSerialization.Deserialize(serialized).Should().Be(value); + } } } diff --git a/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs b/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs index 63a997c77..c292de856 100644 --- a/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs @@ -74,6 +74,15 @@ private void BuildServices() .ReturnsAsync((int _, IEnumerable ids) => ids == null ? new HashSet() : new HashSet(ids, StringComparer.OrdinalIgnoreCase)); + departmentsServiceMock + .Setup(x => x.GetAllMembersForDepartmentUnlimitedAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(new List + { + new DepartmentMember { DepartmentId = DepartmentId, UserId = TestData.Users.TestUser1Id }, + new DepartmentMember { DepartmentId = DepartmentId, UserId = TestData.Users.TestUser2Id }, + new DepartmentMember { DepartmentId = DepartmentId, UserId = TestData.Users.TestUser3Id }, + new DepartmentMember { DepartmentId = DepartmentId, UserId = TestData.Users.TestUser4Id } + }); _authorizationServiceMock.Setup(x => x.CanUserModifyDepartmentAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); _authorizationServiceMock.Setup(x => x.IsUserValidWithinLimitsAsync(It.IsAny(), DepartmentId)).ReturnsAsync(true); diff --git a/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs b/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs index 2e4853665..e5cfc2e21 100644 --- a/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs @@ -41,7 +41,7 @@ public class ChatControllerCommanderLineTests private Mock _cacheProvider; private ChatController _controller; private Activity _activity; - private string _chatEnabledCacheValue; + private bool? _chatEnabledCacheValue; [SetUp] public void SetUp() @@ -57,11 +57,16 @@ public void SetUp() _authorizationService = new Mock(); _authorizationService.Setup(x => x.IsUserValidWithinLimitsAsync(UserId, DepartmentId)).ReturnsAsync(true); _cacheProvider = new Mock(); - _cacheProvider.Setup(x => x.GetStringAsync(It.IsAny())).ReturnsAsync(() => _chatEnabledCacheValue); _cacheProvider - .Setup(x => x.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((key, value, expiration) => _chatEnabledCacheValue = value) - .ReturnsAsync(true); + .Setup(x => x.RetrieveAsync(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns(async (string key, Func> fallback, TimeSpan expiration) => + { + if (_chatEnabledCacheValue.HasValue) + return _chatEnabledCacheValue.Value; + + _chatEnabledCacheValue = await fallback(); + return _chatEnabledCacheValue.Value; + }); _chatPermissionService.Setup(x => x.CanSendAsUnitAsync(UserId, UnitId, DepartmentId)).ReturnsAsync(true); @@ -188,8 +193,25 @@ public async Task CreateIncidentCommanderLine_ReusesTheShortLivedChatEnabledResu x => x.IsEnabledAsync(FeatureFlagKeys.ChatSystem, DepartmentId, It.IsAny(), It.IsAny>()), Times.Once); _cacheProvider.Verify( - x => x.SetStringAsync("chat:enabled:10:requester-user", "1", TimeSpan.FromSeconds(30)), - Times.Once); + x => x.RetrieveAsync("chat:enabled:10:requester-user", It.IsAny>>(), TimeSpan.FromSeconds(30)), + Times.Exactly(2)); + } + + [Test] + public async Task CreateIncidentCommanderLine_ReusesTheShortLivedChatDisabledResult() + { + _authorizationService.Setup(x => x.IsUserValidWithinLimitsAsync(UserId, DepartmentId)).ReturnsAsync(false); + var input = new CreateIncidentCommanderLineInput { CallId = CallId, AsUnitId = UnitId }; + + var first = await _controller.CreateIncidentCommanderLine(input, CancellationToken.None); + var second = await _controller.CreateIncidentCommanderLine(input, CancellationToken.None); + + first.Result.Should().BeOfType(); + second.Result.Should().BeOfType(); + _authorizationService.Verify(x => x.IsUserValidWithinLimitsAsync(UserId, DepartmentId), Times.Once); + _featureToggleService.Verify( + x => x.IsEnabledAsync(FeatureFlagKeys.ChatSystem, DepartmentId, It.IsAny(), It.IsAny>()), + Times.Never); } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index 149e7cd71..417745168 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -1755,18 +1755,14 @@ private async Task ChatEnabledAsync() return false; var cacheKey = $"chat:enabled:{departmentId}:{userId.ToLowerInvariant()}"; - var cached = await _cacheProvider.GetStringAsync(cacheKey); - if (String.Equals(cached, "1", StringComparison.Ordinal)) - return true; - if (String.Equals(cached, "0", StringComparison.Ordinal)) - return false; - - var enabled = await _authorizationService.IsUserValidWithinLimitsAsync(userId, departmentId) && - await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, departmentId); + async Task isChatEnabled() + { + return await _authorizationService.IsUserValidWithinLimitsAsync(userId, departmentId) && + await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, departmentId); + } - await _cacheProvider.SetStringAsync(cacheKey, enabled ? "1" : "0", ChatEnabledCacheLength); - return enabled; + return await _cacheProvider.RetrieveAsync(cacheKey, isChatEnabled, ChatEnabledCacheLength); } ///