Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Core/Resgrid.Config/SessionSecurityConfig.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace Resgrid.Config
namespace Resgrid.Config
{
public static class SessionSecurityConfig
{
Expand All @@ -16,6 +16,13 @@ public static class SessionSecurityConfig
public static int PublicResetAccountLimitPerHour = 3;
public static int PublicResetIpLimitPerHour = 10;
public static int WebBffAccessTokenLifetimeMinutes = 5;
// Eventing tokens are deliberately longer-lived than API tokens. Every hub sets
// CloseOnAuthenticationExpiration, so this value is how often each connected client tears the
// connection down and renegotiates -- a token mint plus an introspection round trip per client,
// per expiry. SessionValidationHubFilter revalidates the session on every hub invocation, so a
// revoked session is cut off well before its token expires; this lifetime only bounds how long a
// purely passive connection can keep receiving broadcasts after revocation.
public static int WebEventingAccessTokenLifetimeMinutes = 30;
public static int ClientMetadataMaximumLength = 256;
public static int UserAgentMaximumLength = 1024;
// Optional local JSON CIDR database. Leave blank to display location as unavailable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
<value>Your department has a currently pending deletion request. If you want this request to proceed there is nothing you need to do after the timestamp below (during our nightly process) your department and all of it’s data will be removed. If you wish to cancel the request and not delete the department press the “Cancel Department Deletion” button below. Please note, any changes to admins and owners will cause the process to not complete, it’s recommended that you only use the system in a read only mode to download data you wish to save.</value>
</data>
<data name="MappingSettingsHeader" xml:space="preserve">
<value>Mapping Settings</value>
<value>Mapping and BigBoard Settings</value>
</data>
<data name="SavedMappingSettings" xml:space="preserve">
<value>Successfully saved Mapping Settings.</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
<value>Your department has a currently pending deletion request. If you want this request to proceed there is nothing you need to do after the timestamp below (during our nightly process) your department and all of it’s data will be removed. If you wish to cancel the request and not delete the department press the “Cancel Department Deletion” button below. Please note, any changes to admins and owners will cause the process to not complete, it’s recommended that you only use the system in a read only mode to download data you wish to save.</value>
</data>
<data name="MappingSettingsHeader" xml:space="preserve">
<value>Mapping Settings</value>
<value>Mapping and BigBoard Settings</value>
</data>
<data name="SavedMappingSettings" xml:space="preserve">
<value>Successfully saved Mapping Settings.</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
<value>Your department has a currently pending deletion request. If you want this request to proceed there is nothing you need to do after the timestamp below (during our nightly process) your department and all of it’s data will be removed. If you wish to cancel the request and not delete the department press the “Cancel Department Deletion” button below. Please note, any changes to admins and owners will cause the process to not complete, it’s recommended that you only use the system in a read only mode to download data you wish to save.</value>
</data>
<data name="MappingSettingsHeader" xml:space="preserve">
<value>Mapping Settings</value>
<value>Mapping and BigBoard Settings</value>
</data>
<data name="SavedMappingSettings" xml:space="preserve">
<value>Successfully saved Mapping Settings.</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
<value>Your department has a currently pending deletion request. If you want this request to proceed there is nothing you need to do after the timestamp below (during our nightly process) your department and all of it’s data will be removed. If you wish to cancel the request and not delete the department press the “Cancel Department Deletion” button below. Please note, any changes to admins and owners will cause the process to not complete, it’s recommended that you only use the system in a read only mode to download data you wish to save.</value>
</data>
<data name="MappingSettingsHeader" xml:space="preserve">
<value>Mapping Settings</value>
<value>Mapping and BigBoard Settings</value>
</data>
<data name="SavedMappingSettings" xml:space="preserve">
<value>Successfully saved Mapping Settings.</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
<value>Your department has a currently pending deletion request. If you want this request to proceed there is nothing you need to do after the timestamp below (during our nightly process) your department and all of it’s data will be removed. If you wish to cancel the request and not delete the department press the “Cancel Department Deletion” button below. Please note, any changes to admins and owners will cause the process to not complete, it’s recommended that you only use the system in a read only mode to download data you wish to save.</value>
</data>
<data name="MappingSettingsHeader" xml:space="preserve">
<value>Mapping Settings</value>
<value>Mapping and BigBoard Settings</value>
</data>
<data name="SavedMappingSettings" xml:space="preserve">
<value>Successfully saved Mapping Settings.</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
<value>Your department has a currently pending deletion request. If you want this request to proceed there is nothing you need to do after the timestamp below (during our nightly process) your department and all of it’s data will be removed. If you wish to cancel the request and not delete the department press the “Cancel Department Deletion” button below. Please note, any changes to admins and owners will cause the process to not complete, it’s recommended that you only use the system in a read only mode to download data you wish to save.</value>
</data>
<data name="MappingSettingsHeader" xml:space="preserve">
<value>Mapping Settings</value>
<value>Mapping and BigBoard Settings</value>
</data>
<data name="SavedMappingSettings" xml:space="preserve">
<value>Successfully saved Mapping Settings.</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
<value>Your department has a currently pending deletion request. If you want this request to proceed there is nothing you need to do after the timestamp below (during our nightly process) your department and all of it’s data will be removed. If you wish to cancel the request and not delete the department press the “Cancel Department Deletion” button below. Please note, any changes to admins and owners will cause the process to not complete, it’s recommended that you only use the system in a read only mode to download data you wish to save.</value>
</data>
<data name="MappingSettingsHeader" xml:space="preserve">
<value>Mapping Settings</value>
<value>Mapping and BigBoard Settings</value>
</data>
<data name="SavedMappingSettings" xml:space="preserve">
<value>Successfully saved Mapping Settings.</value>
Expand Down
10 changes: 10 additions & 0 deletions Core/Resgrid.Services/SystemAuditsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ public SystemAuditsService(ISystemAuditsRepository systemAuditsRepository)
if (auditLog.Data == null)
auditLog.Data = "";

// IpAddress and ServerName are NOT NULL on both the SQL Server and PostgreSQL SystemAudits
// tables. Worker- and scheduler-originated audits have no client request behind them and so
// have no IP to record, and leaving the field unset threw the whole audit away on insert.
// Empty means "no client", which is what the ServerName beside it already says.
if (auditLog.IpAddress == null)
auditLog.IpAddress = "";

if (auditLog.ServerName == null)
auditLog.ServerName = "";

// Several fields carry caller-controlled values (route ids, X-Forwarded-For). Clamp to
// the SystemAudits column sizes so a hostile over-length value degrades to a truncated
// audit row instead of a SqlException that loses the audit entirely.
Expand Down
10 changes: 7 additions & 3 deletions Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using OpenIddict.Validation.AspNetCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Resgrid.Config;
Expand All @@ -20,7 +20,11 @@ namespace Resgrid.Web.Eventing.Hubs
/// per channel (the version rotates on authorization changes),
/// chatuser:{deptId}:{userId} for personal events, chatdept:{deptId} for channel-list updates.
/// </summary>
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme, Policy = ResgridResources.Chat_View)]
// Validated through OpenIddict, not the JwtBearer handler: the API registers an encryption
// certificate, so every access token it issues is an encrypted JWE. The JwtBearer handler here
// has no decryption key (and validates against resgrid.local issuer/audience defaults), so it
// rejects every one of them. The other two hubs already validate through introspection.
[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, Policy = ResgridResources.Chat_View)]
public class ChatHub : Hub
{
private readonly IChatChannelService _chatChannelService;
Expand Down
5 changes: 3 additions & 2 deletions Web/Resgrid.Web.Eventing/Startup.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using Autofac;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
Expand Down Expand Up @@ -35,6 +35,7 @@
using Resgrid.Repositories.DataRepository;
using Resgrid.Services;
using Resgrid.Web.Eventing.Hubs;
using OpenIddict.Validation.AspNetCore;
using static OpenIddict.Abstractions.OpenIddictConstants;
using System.Security.Cryptography.X509Certificates;
using Resgrid.Web.Services.Models;
Expand Down Expand Up @@ -321,7 +322,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddAuthorization(options =>
{
options.AddPolicy(ResgridResources.Chat_View, policy => policy
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
.AddAuthenticationSchemes(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)
.RequireChatAccessClaims());
});

Expand Down
5 changes: 3 additions & 2 deletions Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,9 @@ public async Task<IActionResult> Token()
eventingIdentity.AddClaim(new Claim(SessionClaimTypes.WebEventingOnly, "true"));
foreach (var claim in principal.Claims)
claim.SetDestinations(GetDestinations(claim, principal));
principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(eventingOnly ? 2 :
Math.Max(1, SessionSecurityConfig.WebBffAccessTokenLifetimeMinutes)));
principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(eventingOnly
? Math.Max(1, SessionSecurityConfig.WebEventingAccessTokenLifetimeMinutes)
: Math.Max(1, SessionSecurityConfig.WebBffAccessTokenLifetimeMinutes)));
principal.SetResources(JwtConfig.EventsClientId);

return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
Expand Down
60 changes: 36 additions & 24 deletions Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,6 @@ public async Task<IActionResult> Settings()
else
model.Use24HourTime = false;

model.NewCallFields = await BuildNewCallFieldRowsAsync();
model.UnitStatusThresholds = await BuildUnitStatusThresholdRowsAsync();

var address = await _departmentSettingsService.GetBigBoardCenterAddressDepartmentAsync(DepartmentId);
var zoomLevel = await _departmentSettingsService.GetBigBoardMapZoomLevelForDepartmentAsync(DepartmentId);
var refreshTimer = await _departmentSettingsService.GetBigBoardRefreshTimeForDepartmentAsync(DepartmentId);
Expand Down Expand Up @@ -576,27 +573,6 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.Pe
await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.UnitsSort.ToString(), DepartmentSettingTypes.UnitsSortOrder, cancellationToken);
await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.CallsSort.ToString(), DepartmentSettingTypes.CallsSortOrder, cancellationToken);

await _departmentSettingsService.SaveUnitStatusThresholdsAsync(DepartmentId, new UnitStatusThresholds
{
Thresholds = (model.UnitStatusThresholds ?? new List<UnitStatusThresholdRow>())
.Select(x => new UnitStatusThreshold
{
BaseType = x.BaseType,
// Entered in minutes, stored in seconds — the board works in seconds and a
// dispatcher should never have to think in them.
WarnSeconds = Math.Max(0, x.WarnMinutes) * 60,
AlertSeconds = Math.Max(0, x.AlertMinutes) * 60
})
.ToList()
}, cancellationToken);

await _departmentSettingsService.SaveNewCallFieldPolicyAsync(DepartmentId, new NewCallFieldPolicy
{
Rules = (model.NewCallFields ?? new List<NewCallFieldPolicyRow>())
.Select(x => new NewCallFieldRule { Key = x.Key, Visible = x.Visible, Required = x.Required })
.ToList()
}, cancellationToken);

// Coordinates the operator typed are stored verbatim. Leaving both blank means "work it
// out from our address" -- the service geocodes it and stores the result, so a department
// that never touches these fields still gets every map centred on itself instead of on
Expand Down Expand Up @@ -1874,6 +1850,7 @@ public async Task<IActionResult> DispatchSettings()
model.UnitClearStatus = await _departmentSettingsService.GetUnitCallReleaseStatusToSetAsync(DepartmentId);
model.PersonnelOnUnitSetUnitStatus = await _departmentSettingsService.GetPersonnelOnUnitSetUnitStatusAsync(DepartmentId);
model.AutoEnableCheckInTimers = await _departmentSettingsService.GetCheckInTimersAutoEnableForNewCallsAsync(DepartmentId);
model.NewCallFields = await BuildNewCallFieldRowsAsync();

await PopulateDispatchSettingsSupportingDataAsync(model);
await PopulateDispatchSettingsUnitTypeOverridesAsync(model,
Expand Down Expand Up @@ -1927,6 +1904,13 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.Pe
await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.AutoEnableCheckInTimers.ToString(),
DepartmentSettingTypes.CheckInTimersAutoEnableForNewCalls, cancellationToken);

await _departmentSettingsService.SaveNewCallFieldPolicyAsync(DepartmentId, new NewCallFieldPolicy
{
Rules = (model.NewCallFields ?? new List<NewCallFieldPolicyRow>())
.Select(x => new NewCallFieldRule { Key = x.Key, Visible = x.Visible, Required = x.Required })
.ToList()
}, cancellationToken);

if (await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId))
{
var mode = Enum.IsDefined(typeof(DispatchRecommendationModes), model.DispatchRecommendationMode)
Expand Down Expand Up @@ -1959,6 +1943,12 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.Au

await PopulateDispatchSettingsUnitTypeOverridesAsync(model);
await PopulateRunCardRecommendationSettingsAsync(model, false);

// A rejected post redisplays the operator's own ticks -- they round-trip through model
// binding. Only rebuild from storage when nothing came back at all.
if (model.NewCallFields == null || model.NewCallFields.Count == 0)
model.NewCallFields = await BuildNewCallFieldRowsAsync();

model.SaveSuccess = false;
return View(model);
}
Expand Down Expand Up @@ -2443,6 +2433,7 @@ public async Task<IActionResult> MappingSettings()
model.UseMapboxOverride = await _departmentSettingsService.GetMappingUseMapboxOverrideAsync(DepartmentId);
model.MapboxStyleUrl = await _departmentSettingsService.GetMappingMapboxStyleUrlAsync(DepartmentId);
model.MapboxAccessToken = await _departmentSettingsService.GetMappingMapboxAccessTokenAsync(DepartmentId);
model.UnitStatusThresholds = await BuildUnitStatusThresholdRowsAsync();

return View(model);
}
Expand All @@ -2469,6 +2460,22 @@ public async Task<IActionResult> MappingSettings(MappingSettingsView model, Canc

if (ModelState.IsValid)
{
await _departmentSettingsService.SaveUnitStatusThresholdsAsync(DepartmentId, new UnitStatusThresholds
{
Thresholds = (model.UnitStatusThresholds ?? new List<UnitStatusThresholdRow>())
.Select(x => new UnitStatusThreshold
{
BaseType = x.BaseType,
// Entered in minutes, stored in seconds — the board works in seconds and a
// dispatcher should never have to think in them. Clamped to the largest minute
// count that still converts: the form binds a raw int, and without the ceiling a
// posted value over int.MaxValue / 60 wraps negative and silently reads as 0.
WarnSeconds = Math.Min(Math.Max(0, x.WarnMinutes), int.MaxValue / 60) * 60,
AlertSeconds = Math.Min(Math.Max(0, x.AlertMinutes), int.MaxValue / 60) * 60
})
.ToList()
}, cancellationToken);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.PersonnelLocationTTL.ToString(),
DepartmentSettingTypes.MappingPersonnelLocationTTL, cancellationToken);
await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.UnitLocationTTL.ToString(),
Expand Down Expand Up @@ -2497,6 +2504,11 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.Ma
return View(model);
}

// A rejected post redisplays the operator's own numbers -- they round-trip through model
// binding. Only rebuild from storage when nothing came back at all.
if (model.UnitStatusThresholds == null || model.UnitStatusThresholds.Count == 0)
model.UnitStatusThresholds = await BuildUnitStatusThresholdRowsAsync();

model.SaveSuccess = false;
return View(model);
}
Expand Down
6 changes: 6 additions & 0 deletions Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,12 @@ public async Task<IActionResult> PersonnelActionButtonsPartial()
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task<IActionResult> EditUserProfile(string userId)
{
// No userId means "my profile" -- links that come back here from a sub-page (Active
// Sessions, Change Password) have no user to name. Without this the authorization
// check below is handed a null target and answers 401 for the user's own profile.
if (string.IsNullOrWhiteSpace(userId))
userId = UserId;

var model = new EditProfileModel();
model.ApiUrl = Config.SystemBehaviorConfig.ResgridApiBaseUrl;
model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId);
Expand Down
16 changes: 2 additions & 14 deletions Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,6 @@ public class DepartmentSettingsModel : BaseUserModel
public string MapZoomLevel { get; set; }
public string RefreshTime { get; set; }

/// <summary>
/// New-call field policy, one row per configurable built-in field. Bound as a list so the admin
/// screen can render a Visible/Required grid without a per-field property explosion.
/// </summary>
public List<NewCallFieldPolicyRow> NewCallFields { get; set; } = new List<NewCallFieldPolicyRow>();

/// <summary>
/// Time-in-status thresholds, one row per canonical status meaning. Entered in minutes because
/// that is how dispatchers talk about them ("four minutes without reporting departed").
/// </summary>
public List<UnitStatusThresholdRow> UnitStatusThresholds { get; set; } = new List<UnitStatusThresholdRow>();

[StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")]
public string MapCenterPointAddressAddress1 { get; set; }

Expand Down Expand Up @@ -100,7 +88,7 @@ public void SetUsers(List<IdentityUser> users, List<PersonName> names)
}
}
}
/// <summary>One configurable built-in new-call field on the department settings screen.</summary>
/// <summary>One configurable built-in new-call field on the call and dispatch settings screen.</summary>
public class NewCallFieldPolicyRow
{
/// <summary>Stable key from Resgrid.Model.NewCallFieldKeys.</summary>
Expand All @@ -110,7 +98,7 @@ public class NewCallFieldPolicyRow

public bool Required { get; set; }
}
/// <summary>One time-in-status threshold row on the department settings screen.</summary>
/// <summary>One time-in-status threshold row on the mapping and big board settings screen.</summary>
public class UnitStatusThresholdRow
{
/// <summary>The ActionBaseTypes value this row configures.</summary>
Expand Down
Loading
Loading