diff --git a/Core/Resgrid.Config/SecurityConfig.cs b/Core/Resgrid.Config/SecurityConfig.cs
index 30680c47..177c0f41 100644
--- a/Core/Resgrid.Config/SecurityConfig.cs
+++ b/Core/Resgrid.Config/SecurityConfig.cs
@@ -16,6 +16,15 @@ public static class SecurityConfig
};
+ ///
+ /// System-level API key used by the SMTP Relay in hosted multi-department mode.
+ /// When the X-Resgrid-SystemApiKey header matches this value, the request bypasses
+ /// OAuth 2.0 authentication and is granted full cross-department access.
+ /// The department for each operation is determined by the DepartmentId field in the
+ /// request body/query parameters, not by the auth token.
+ ///
+ public static string SystemApiKey = "";
+
// ── Encryption ───────────────────────────────────────────────────────────────
/// AES-256 master key used by IEncryptionService for system-wide encryption.
diff --git a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs
index 9c0426d2..392d41a4 100644
--- a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs
+++ b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs
@@ -24,6 +24,13 @@ public static class Data
public const string TimeZone = "TimeZone";
public const string DisplayName = "DisplayName";
public const string UserId = "UserId";
+
+ ///
+ /// Claim added to service-account tokens (client_credentials, system API keys)
+ /// so downstream controllers can identify them as non-user principals that bypass
+ /// per-user authorization checks and support cross-department operation.
+ ///
+ public const string ServiceAccount = "ServiceAccount";
}
public static class Resources
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs
index 45dc0734..891bb8f4 100644
--- a/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs
+++ b/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs
@@ -26,7 +26,8 @@ namespace Resgrid.Web.Services.Controllers.v4
[Route("api/v{VersionId:apiVersion}/[controller]")]
[ApiVersion("4.0")]
[ApiExplorerSettings(GroupName = "v4")]
- public class CallFilesController : V4AuthenticatedApiControllerbase
+ [Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")]
+ public class CallFilesController : V4AuthenticatedApiControllerbaseSystemAuth
{
#region Members and Constructors
private readonly ICallsService _callsService;
@@ -205,7 +206,9 @@ public async Task> SaveCallFile(SaveCallFileInp
return Ok(result);
}
- if (call.DepartmentId != DepartmentId)
+ var effectiveDepartmentId = GetEffectiveDepartmentId(input.DepartmentId);
+
+ if (call.DepartmentId != effectiveDepartmentId)
return Unauthorized();
if (call.State != (int)CallStates.Active)
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs
index 552649b2..08f9caa2 100644
--- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs
+++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs
@@ -32,7 +32,8 @@ namespace Resgrid.Web.Services.Controllers.v4
[Route("api/v{VersionId:apiVersion}/[controller]")]
[ApiVersion("4.0")]
[ApiExplorerSettings(GroupName = "v4")]
- public class CallsController : V4AuthenticatedApiControllerbase
+ [Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")]
+ public class CallsController : V4AuthenticatedApiControllerbaseSystemAuth
{
#region Members and Constructors
private readonly ICallsService _callsService;
@@ -161,7 +162,7 @@ public async Task> GetActiveCalls()
[HttpGet("GetCall")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize(Policy = ResgridResources.Call_View)]
- public async Task> GetCall(string callId)
+ public async Task> GetCall(string callId, [FromQuery] string departmentId = null)
{
if (String.IsNullOrWhiteSpace(callId))
return BadRequest();
@@ -175,14 +176,16 @@ public async Task> GetCall(string callId)
return Ok(result);
}
- if (c.DepartmentId != DepartmentId)
+ var effectiveDepartmentId = GetEffectiveDepartmentId(departmentId);
+
+ if (c.DepartmentId != effectiveDepartmentId)
return Unauthorized();
- if (!await _authorizationService.CanUserViewCallAsync(UserId, int.Parse(callId)))
+ if (!IsSystemApiKeyRequest && !await _authorizationService.CanUserViewCallAsync(UserId, int.Parse(callId)))
return Unauthorized();
c = await _callsService.PopulateCallData(c, false, true, true, false, false, false, true, true, true);
- var destinationPoi = await GetValidatedDestinationPoiAsync(c.DestinationPoiId);
+ var destinationPoi = await GetValidatedDestinationPoiAsync(c.DestinationPoiId, effectiveDepartmentId);
string address = "";
if (String.IsNullOrWhiteSpace(c.Address) && c.HasValidGeolocationData())
@@ -209,7 +212,7 @@ public async Task> GetCall(string callId)
result.Data = ConvertCall(c, protocols, address, TimeZone, destinationPoi);
// Populate UDF values
- var udfValues = await _userDefinedFieldsService.GetFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Call, c.CallId.ToString());
+ var udfValues = await _userDefinedFieldsService.GetFieldValuesForEntityAsync(effectiveDepartmentId, (int)UdfEntityType.Call, c.CallId.ToString());
if (udfValues != null && udfValues.Any())
{
result.Data.UdfValues = udfValues.Select(v => new UdfFieldValueResultData
@@ -545,27 +548,35 @@ public async Task> SaveCall([FromBody] NewCallInput
{
var result = new SaveCallResult();
- var canDoOperation = await _authorizationService.CanUserCreateCallAsync(UserId, DepartmentId);
+ var effectiveDepartmentId = GetEffectiveDepartmentId(newCallInput.DepartmentId);
- if (!canDoOperation)
- return Unauthorized();
+ if (!IsSystemApiKeyRequest)
+ {
+ var canDoOperation = await _authorizationService.CanUserCreateCallAsync(UserId, effectiveDepartmentId);
+ if (!canDoOperation)
+ return Unauthorized();
+ }
if (!ModelState.IsValid)
return BadRequest();
- var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId);
- var activeUsers = await _departmentsService.GetAllMembersForDepartmentAsync(DepartmentId);
- var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId);
- var roles = await _personnelRolesService.GetAllRolesForDepartmentAsync(DepartmentId);
- var units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId);
- var destinationPoi = await GetValidatedDestinationPoiAsync(newCallInput.DestinationPoiId);
+ var department = await _departmentsService.GetDepartmentByIdAsync(effectiveDepartmentId);
+
+ if (department == null)
+ return BadRequest($"Department not found: {effectiveDepartmentId}");
+
+ var activeUsers = await _departmentsService.GetAllMembersForDepartmentAsync(effectiveDepartmentId);
+ var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(effectiveDepartmentId);
+ var roles = await _personnelRolesService.GetAllRolesForDepartmentAsync(effectiveDepartmentId);
+ var units = await _unitsService.GetUnitsForDepartmentAsync(effectiveDepartmentId);
+ var destinationPoi = await GetValidatedDestinationPoiAsync(newCallInput.DestinationPoiId, effectiveDepartmentId);
if (newCallInput.DestinationPoiId.HasValue && newCallInput.DestinationPoiId.Value > 0 && destinationPoi == null)
return BadRequest();
var call = new Call
{
- DepartmentId = DepartmentId,
+ DepartmentId = effectiveDepartmentId,
ReportingUserId = UserId,
Priority = newCallInput.Priority,
Name = newCallInput.Name,
@@ -635,13 +646,13 @@ public async Task> SaveCall([FromBody] NewCallInput
call.CheckInTimersEnabled = newCallInput.CheckInTimersEnabled.Value;
else
{
- var autoEnable = await _departmentSettingsService.GetCheckInTimersAutoEnableForNewCallsAsync(DepartmentId);
+ var autoEnable = await _departmentSettingsService.GetCheckInTimersAutoEnableForNewCallsAsync(effectiveDepartmentId);
call.CheckInTimersEnabled = autoEnable;
}
if (!String.IsNullOrWhiteSpace(newCallInput.Type) && newCallInput.Type != "No Type")
{
- var callTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId);
+ var callTypes = await _callsService.GetCallTypesForDepartmentAsync(effectiveDepartmentId);
var type = callTypes.FirstOrDefault(x => x.Type == newCallInput.Type);
if (type != null)
@@ -649,13 +660,13 @@ public async Task> SaveCall([FromBody] NewCallInput
call.Type = type.Type;
}
}
- var users = await _departmentsService.GetAllUsersForDepartmentAsync(DepartmentId);
+ var users = await _departmentsService.GetAllUsersForDepartmentAsync(effectiveDepartmentId);
call.Dispatches = new Collection();
call.GroupDispatches = new List();
call.RoleDispatches = new List();
call.UnitDispatches = new List();
- if (newCallInput.DispatchList == "0")
+ if (!IsSystemApiKeyRequest && (newCallInput.DispatchList == "0" || string.IsNullOrWhiteSpace(newCallInput.DispatchList)))
{
// Use case, existing clients and non-ionic2 app this will be null dispatch all users. Or we've specified everyone (0).
foreach (var u in users)
@@ -665,7 +676,7 @@ public async Task> SaveCall([FromBody] NewCallInput
call.Dispatches.Add(cd);
}
}
- else
+ else if (!string.IsNullOrWhiteSpace(newCallInput.DispatchList) && newCallInput.DispatchList != "0")
{
var dispatch = newCallInput.DispatchList.Split(char.Parse("|"));
@@ -751,7 +762,7 @@ public async Task> SaveCall([FromBody] NewCallInput
//OutboundEventProvider handler = new OutboundEventProvider.CallAddedTopicHandler();
//OutboundEventProvider..Handle(new CallAddedEvent() { DepartmentId = DepartmentId, Call = savedCall });
- _eventAggregator.SendMessage(new CallAddedEvent() { DepartmentId = DepartmentId, Call = savedCall });
+ _eventAggregator.SendMessage(new CallAddedEvent() { DepartmentId = effectiveDepartmentId, Call = savedCall });
if (shouldDispatchNow && ((call.GroupDispatches != null && call.GroupDispatches.Any()) || (call.UnitDispatches != null && call.UnitDispatches.Any())))
{
@@ -806,7 +817,7 @@ await _callDispatchStatusService.ApplyDispatchStatusesAsync(savedCall,
// Save UDF field values if supplied
if (newCallInput.UdfValues != null && newCallInput.UdfValues.Any())
{
- bool isDeptAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin();
+ bool isDeptAdmin = IsSystemApiKeyRequest || ClaimsAuthorizationHelper.IsUserDepartmentAdmin();
bool isGroupAdmin = HttpContext.User.Claims
.Any(c => c.Type.StartsWith(ResgridClaimTypes.Resources.Group + "/", StringComparison.Ordinal)
&& c.Value == ResgridClaimTypes.Actions.Update);
@@ -816,7 +827,7 @@ await _callDispatchStatusService.ApplyDispatchStatusesAsync(savedCall,
UdfFieldId = v.UdfFieldId,
Value = v.Value
}).ToList();
- await _userDefinedFieldsService.SaveFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Call, savedCall.CallId.ToString(), udfValues, UserId, isDeptAdmin, isGroupAdmin, cancellationToken);
+ await _userDefinedFieldsService.SaveFieldValuesForEntityAsync(effectiveDepartmentId, (int)UdfEntityType.Call, savedCall.CallId.ToString(), udfValues, UserId, isDeptAdmin, isGroupAdmin, cancellationToken);
}
result.Id = savedCall.CallId.ToString();
@@ -1819,12 +1830,13 @@ public static CallResultData ConvertCall(Call call, List proto
return callResult;
}
- private async Task GetValidatedDestinationPoiAsync(int? destinationPoiId)
+ private async Task GetValidatedDestinationPoiAsync(int? destinationPoiId, int? departmentIdOverride = null)
{
if (!destinationPoiId.HasValue || destinationPoiId.Value <= 0)
return null;
- return await _mappingService.GetDestinationPOIByIdAsync(DepartmentId, destinationPoiId.Value);
+ var deptId = departmentIdOverride ?? DepartmentId;
+ return await _mappingService.GetDestinationPOIByIdAsync(deptId, destinationPoiId.Value);
}
}
}
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs
index 40a2f0c8..2735d6b5 100644
--- a/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs
+++ b/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs
@@ -17,6 +17,9 @@
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
+using System.Text;
+using System.Security.Cryptography;
+using Resgrid.Providers.Claims;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Resgrid.Web.Services.Controllers.v4
@@ -223,6 +226,156 @@ public async Task Token()
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
+ else if (request != null && request.IsClientCredentialsGrantType())
+ {
+ // Client Credentials grant_type for SMTP Relay single-department deployments.
+ // Validates client_id and client_secret against department credentials or system-level config.
+
+ SystemAudit audit = new SystemAudit();
+ audit.System = (int)SystemAuditSystems.Api;
+ audit.Type = (int)SystemAuditTypes.Login;
+ audit.Username = request.ClientId;
+ audit.Successful = false;
+ audit.IpAddress = IpAddressHelper.GetRequestIP(Request, true);
+ audit.ServerName = Environment.MachineName;
+ audit.Data = $"V4 Token (client_credentials), {Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}";
+
+ if (string.IsNullOrWhiteSpace(request.ClientId) || string.IsNullOrWhiteSpace(request.ClientSecret))
+ {
+ await _systemAuditsService.SaveSystemAuditAsync(audit);
+
+ var properties = new AuthenticationProperties(new Dictionary
+ {
+ [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
+ [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
+ "The client_id and client_secret are required for client_credentials grant."
+ });
+
+ return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
+ }
+
+ // First, check system-level credentials (timing-safe comparison)
+ if (Config.SecurityConfig.SystemLoginCredentials.ContainsKey(request.ClientId) &&
+ FixedTimeSecretEquals(Config.SecurityConfig.SystemLoginCredentials[request.ClientId], request.ClientSecret))
+ {
+ audit.Successful = true;
+ await _systemAuditsService.SaveSystemAuditAsync(audit);
+
+ // Create a system-level service principal with all claims
+ var identity = new ClaimsIdentity(
+ OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
+ Claims.Name,
+ Claims.Role);
+
+ identity.AddClaim(new Claim(Claims.Subject, $"system_{request.ClientId}")
+ .SetDestinations(Destinations.AccessToken, Destinations.IdentityToken));
+ identity.AddClaim(new Claim(Claims.Name, $"System Account ({request.ClientId})")
+ .SetDestinations(Destinations.AccessToken, Destinations.IdentityToken));
+ identity.AddClaim(new Claim(ClaimTypes.PrimarySid, $"system_{request.ClientId}")
+ .SetDestinations(Destinations.AccessToken));
+ identity.AddClaim(new Claim(ClaimTypes.PrimaryGroupSid, "0")
+ .SetDestinations(Destinations.AccessToken));
+ identity.AddClaim(new Claim(ClaimTypes.GivenName, "SMTP Relay System")
+ .SetDestinations(Destinations.AccessToken, Destinations.IdentityToken));
+ identity.AddClaim(new Claim(ResgridClaimTypes.Data.DisplayName, "SMTP Relay System")
+ .SetDestinations(Destinations.AccessToken, Destinations.IdentityToken));
+ identity.AddClaim(new Claim(ResgridClaimTypes.Data.ServiceAccount, "true")
+ .SetDestinations(Destinations.AccessToken, Destinations.IdentityToken));
+
+ // Add all resource claims for full access
+ AddAllResourceClaims(identity);
+
+ var principal = new ClaimsPrincipal(identity);
+
+ principal.SetScopes(new[]
+ {
+ Scopes.OpenId,
+ Scopes.Email,
+ Scopes.Profile
+ }.Intersect(request.GetScopes()));
+
+ principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(OidcConfig.AccessTokenExpiryMinutes));
+
+ return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
+ }
+
+ // Second, try department-level credentials via department code + shared secret
+ var department = await _departmentsService.GetDepartmentByNameAsync(request.ClientId);
+
+ if (department == null)
+ {
+ await _systemAuditsService.SaveSystemAuditAsync(audit);
+
+ var properties = new AuthenticationProperties(new Dictionary
+ {
+ [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
+ [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
+ "The client_id or client_secret is invalid."
+ });
+
+ return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
+ }
+
+ if (string.IsNullOrWhiteSpace(department.SharedSecret) ||
+ !FixedTimeSecretEquals(department.SharedSecret, request.ClientSecret))
+ {
+ await _systemAuditsService.SaveSystemAuditAsync(audit);
+
+ var properties = new AuthenticationProperties(new Dictionary
+ {
+ [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
+ [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
+ "The client_id or client_secret is invalid."
+ });
+
+ return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
+ }
+
+ audit.Successful = true;
+ audit.UserId = department.ManagingUserId;
+ await _systemAuditsService.SaveSystemAuditAsync(audit);
+
+ // Create a department-scoped service principal
+ var deptIdentity = new ClaimsIdentity(
+ OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
+ Claims.Name,
+ Claims.Role);
+
+ deptIdentity.AddClaim(new Claim(Claims.Subject, $"dept_{department.DepartmentId}_svc")
+ .SetDestinations(Destinations.AccessToken, Destinations.IdentityToken));
+ deptIdentity.AddClaim(new Claim(Claims.Name, $"svc_{request.ClientId}")
+ .SetDestinations(Destinations.AccessToken, Destinations.IdentityToken));
+ deptIdentity.AddClaim(new Claim(ClaimTypes.PrimarySid, $"dept_{department.DepartmentId}_svc")
+ .SetDestinations(Destinations.AccessToken));
+ deptIdentity.AddClaim(new Claim(ClaimTypes.PrimaryGroupSid, department.DepartmentId.ToString())
+ .SetDestinations(Destinations.AccessToken));
+ deptIdentity.AddClaim(new Claim(ClaimTypes.Actor, department.Name)
+ .SetDestinations(Destinations.AccessToken));
+
+ // Add all resource claims for full department access
+ AddAllResourceClaims(deptIdentity);
+
+ var deptPrincipal = new ClaimsPrincipal(deptIdentity);
+
+ deptPrincipal.SetScopes(new[]
+ {
+ Scopes.OpenId,
+ Scopes.Email,
+ Scopes.Profile
+ }.Intersect(request.GetScopes()));
+
+ if (request.GetScopes() != null && request.GetScopes().Contains("mobile"))
+ {
+ deptPrincipal.SetAccessTokenLifetime(TimeSpan.FromMinutes(OidcConfig.AccessTokenExpiryMinutes));
+ }
+ else
+ {
+ deptPrincipal.SetAccessTokenLifetime(TimeSpan.FromMinutes(OidcConfig.AccessTokenExpiryMinutes));
+ }
+
+ return SignIn(deptPrincipal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
+ }
+
throw new NotImplementedException("The specified grant type is not implemented.");
}
@@ -707,5 +860,83 @@ private IEnumerable GetDestinations(Claim claim, ClaimsPrincipal princip
yield break;
}
}
+
+ ///
+ /// Adds all Resgrid resource claims (View, Create, Update, Delete) to the given identity.
+ /// Used for system-level and client-credentials service principals that need full access.
+ ///
+ private static void AddAllResourceClaims(ClaimsIdentity identity)
+ {
+ var resources = new[]
+ {
+ ResgridClaimTypes.Resources.Department,
+ ResgridClaimTypes.Resources.Personnel,
+ ResgridClaimTypes.Resources.Call,
+ ResgridClaimTypes.Resources.Log,
+ ResgridClaimTypes.Resources.Action,
+ ResgridClaimTypes.Resources.Staffing,
+ ResgridClaimTypes.Resources.Unit,
+ ResgridClaimTypes.Resources.Group,
+ ResgridClaimTypes.Resources.UnitLog,
+ ResgridClaimTypes.Resources.Messages,
+ ResgridClaimTypes.Resources.Role,
+ ResgridClaimTypes.Resources.Profile,
+ ResgridClaimTypes.Resources.Reports,
+ ResgridClaimTypes.Resources.GenericGroup,
+ ResgridClaimTypes.Resources.Documents,
+ ResgridClaimTypes.Resources.Notes,
+ ResgridClaimTypes.Resources.Schedule,
+ ResgridClaimTypes.Resources.Shift,
+ ResgridClaimTypes.Resources.Training,
+ ResgridClaimTypes.Resources.PersonalInfo,
+ ResgridClaimTypes.Resources.Inventory,
+ ResgridClaimTypes.Resources.Command,
+ ResgridClaimTypes.Resources.Connect,
+ ResgridClaimTypes.Resources.Protocols,
+ ResgridClaimTypes.Resources.Forms,
+ ResgridClaimTypes.Resources.Voice,
+ ResgridClaimTypes.Resources.CustomStates,
+ ResgridClaimTypes.Resources.Contacts,
+ ResgridClaimTypes.Resources.Workflow,
+ ResgridClaimTypes.Resources.WorkflowCredential,
+ ResgridClaimTypes.Resources.WorkflowRun,
+ ResgridClaimTypes.Resources.Sso,
+ ResgridClaimTypes.Resources.Scim,
+ ResgridClaimTypes.Resources.Udf,
+ ResgridClaimTypes.Resources.Route,
+ ResgridClaimTypes.Resources.CommunicationTest,
+ ResgridClaimTypes.Resources.WeatherAlert
+ };
+
+ var actions = new[]
+ {
+ ResgridClaimTypes.Actions.View,
+ ResgridClaimTypes.Actions.Create,
+ ResgridClaimTypes.Actions.Update,
+ ResgridClaimTypes.Actions.Delete
+ };
+
+ foreach (var resource in resources)
+ {
+ foreach (var action in actions)
+ {
+ identity.AddClaim(new Claim(resource, action)
+ .SetDestinations(Destinations.AccessToken));
+ }
+ }
+ }
+ ///
+ /// Performs a timing-safe comparison of two secret strings to prevent timing attacks.
+ ///
+ private static bool FixedTimeSecretEquals(string stored, string provided)
+ {
+ if (stored == null || provided == null)
+ return false;
+
+ var storedBytes = Encoding.UTF8.GetBytes(stored);
+ var providedBytes = Encoding.UTF8.GetBytes(provided);
+
+ return CryptographicOperations.FixedTimeEquals(storedBytes, providedBytes);
+ }
}
}
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DepartmentsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DepartmentsController.cs
new file mode 100644
index 00000000..b377ee2c
--- /dev/null
+++ b/Web/Resgrid.Web.Services/Controllers/v4/DepartmentsController.cs
@@ -0,0 +1,87 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Resgrid.Model.Services;
+using Resgrid.Providers.Claims;
+using System.Threading.Tasks;
+using Resgrid.Web.Services.Helpers;
+using Resgrid.Web.Services.Models.v4.Departments;
+using System;
+
+namespace Resgrid.Web.Services.Controllers.v4
+{
+ ///
+ /// Department-level lookup operations used by external integrations such as the SMTP relay
+ /// to resolve dispatch codes to departments.
+ ///
+ [Route("api/v{VersionId:apiVersion}/[controller]")]
+ [ApiVersion("4.0")]
+ [ApiExplorerSettings(GroupName = "v4")]
+ [Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")]
+ public class DepartmentsController : V4AuthenticatedApiControllerbaseSystemAuth
+ {
+ #region Members and Constructors
+ private readonly IDepartmentSettingsService _departmentSettingsService;
+ private readonly IDepartmentsService _departmentsService;
+
+ public DepartmentsController(IDepartmentSettingsService departmentSettingsService,
+ IDepartmentsService departmentsService)
+ {
+ _departmentSettingsService = departmentSettingsService;
+ _departmentsService = departmentsService;
+ }
+ #endregion Members and Constructors
+
+ ///
+ /// Resolves a dispatch email code to the department it belongs to.
+ /// Used by the SMTP relay in single-department or hosted multi-department mode
+ /// when the department cannot be determined from the email domain alone.
+ ///
+ /// The dispatch email code (local part of the email address) to look up.
+ /// DepartmentResult with the matching department, or a not-found response.
+ [HttpGet("GetDepartmentByDispatchCode")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.Department_View)]
+ public async Task> GetDepartmentByDispatchCode(string code)
+ {
+ var result = new DepartmentResult();
+
+ if (String.IsNullOrWhiteSpace(code))
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ var departmentId = await _departmentSettingsService.GetDepartmentIdForDispatchEmailAsync(code);
+
+ if (!departmentId.HasValue || departmentId.Value <= 0)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ // In SystemApiKey mode, the relay is authorized to look up any department.
+ // In OAuth mode, validate that the token's department matches the resolved department.
+ if (!IsSystemApiKeyRequest && departmentId.Value != DepartmentId)
+ return Unauthorized();
+
+ var department = await _departmentsService.GetDepartmentByIdAsync(departmentId.Value, false);
+
+ if (department == null)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ result.Data.DepartmentId = department.DepartmentId.ToString();
+ result.Data.Name = department.Name;
+ result.Data.Code = department.Code;
+
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+
+ return Ok(result);
+ }
+ }
+}
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/GroupsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/GroupsController.cs
index 77bd10e1..26ea691c 100644
--- a/Web/Resgrid.Web.Services/Controllers/v4/GroupsController.cs
+++ b/Web/Resgrid.Web.Services/Controllers/v4/GroupsController.cs
@@ -17,7 +17,8 @@ namespace Resgrid.Web.Services.Controllers.v4
[Route("api/v{VersionId:apiVersion}/[controller]")]
[ApiVersion("4.0")]
[ApiExplorerSettings(GroupName = "v4")]
- public class GroupsController : V4AuthenticatedApiControllerbase
+ [Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")]
+ public class GroupsController : V4AuthenticatedApiControllerbaseSystemAuth
{
#region Members and Constructors
private readonly IDepartmentGroupsService _departmentGroupsService;
@@ -100,6 +101,128 @@ public async Task> GetAllGroups()
return Ok(result);
}
+ ///
+ /// Resolves a group dispatch email code to the corresponding group.
+ /// Used by the SMTP relay to resolve random dispatch codes (e.g. "XK7M2N") to numeric group IDs for DispatchList.
+ /// In SystemApiKey (hosted multi-department) mode, departmentId is optional — the code alone resolves to the owning group.
+ /// In OAuth mode, the group's department is validated against the token's department.
+ ///
+ /// The dispatch email code (local part of the email address, e.g. a 6-char random string).
+ /// Optional department scope. In SystemApiKey mode: if provided, validates the group belongs to this department. If omitted, returns any group matching the code. In OAuth mode: ignored, always validates against the token.
+ /// GroupResult with the matching group (including its DepartmentId), or a not-found response.
+ [HttpGet("GetGroupByDispatchCode")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.Group_View)]
+ public async Task> GetGroupByDispatchCode(string code, [FromQuery] string departmentId = null)
+ {
+ var result = new GroupResult();
+
+ if (String.IsNullOrWhiteSpace(code))
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ var group = await _departmentGroupsService.GetGroupByDispatchEmailCodeAsync(code);
+
+ if (group == null)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ // In SystemApiKey mode: if departmentId param is provided, validate the group belongs to it.
+ // If no departmentId is provided (code-only lookup), allow cross-department resolution.
+ // In OAuth mode: always validate against the token's department.
+ if (!IsSystemApiKeyRequest)
+ {
+ if (group.DepartmentId != DepartmentId)
+ return Unauthorized();
+ }
+ else if (!string.IsNullOrWhiteSpace(departmentId))
+ {
+ // DepartmentId was provided explicitly — must be valid and match
+ if (!int.TryParse(departmentId, out var deptId) || deptId <= 0)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+ if (group.DepartmentId != deptId)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+ }
+
+ result.Data = ConvertGroupData(group);
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Resolves a group message email code to the corresponding group.
+ /// Used by the SMTP relay to resolve random message codes (e.g. "XK7M2N") to numeric group IDs for Message creation.
+ /// In SystemApiKey (hosted multi-department) mode, departmentId is optional — the code alone resolves to the owning group.
+ /// In OAuth mode, the group's department is validated against the token's department.
+ ///
+ /// The message email code (local part of the email address, e.g. a 6-char random string).
+ /// Optional department scope. In SystemApiKey mode: if provided, validates the group belongs to this department. If omitted, returns any group matching the code. In OAuth mode: ignored, always validates against the token.
+ /// GroupResult with the matching group (including its DepartmentId), or a not-found response.
+ [HttpGet("GetGroupByMessageCode")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.Group_View)]
+ public async Task> GetGroupByMessageCode(string code, [FromQuery] string departmentId = null)
+ {
+ var result = new GroupResult();
+
+ if (String.IsNullOrWhiteSpace(code))
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ var group = await _departmentGroupsService.GetGroupByMessageEmailCodeAsync(code);
+
+ if (group == null)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ // In SystemApiKey mode: if departmentId param is provided, validate the group belongs to it.
+ // If no departmentId is provided (code-only lookup), allow cross-department resolution.
+ // In OAuth mode: always validate against the token's department.
+ if (!IsSystemApiKeyRequest)
+ {
+ if (group.DepartmentId != DepartmentId)
+ return Unauthorized();
+ }
+ else if (!string.IsNullOrWhiteSpace(departmentId))
+ {
+ // DepartmentId was provided explicitly — must be valid and match
+ if (!int.TryParse(departmentId, out var deptId) || deptId <= 0)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+ if (group.DepartmentId != deptId)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+ }
+
+ result.Data = ConvertGroupData(group);
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+
+ return Ok(result);
+ }
+
public static GroupResultData ConvertGroupData(DepartmentGroup group)
{
var result = new GroupResultData();
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RolesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RolesController.cs
index 1fa9ad37..be5b05d3 100644
--- a/Web/Resgrid.Web.Services/Controllers/v4/RolesController.cs
+++ b/Web/Resgrid.Web.Services/Controllers/v4/RolesController.cs
@@ -8,6 +8,7 @@
using Resgrid.Model;
using Resgrid.Web.Services.Models.v4.Roles;
using System.Linq;
+using System;
namespace Resgrid.Web.Services.Controllers.v4
{
@@ -17,7 +18,8 @@ namespace Resgrid.Web.Services.Controllers.v4
[Route("api/v{VersionId:apiVersion}/[controller]")]
[ApiVersion("4.0")]
[ApiExplorerSettings(GroupName = "v4")]
- public class RolesController : V4AuthenticatedApiControllerbase
+ [Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")]
+ public class RolesController : V4AuthenticatedApiControllerbaseSystemAuth
{
#region Members and Constructors
private readonly IPersonnelRolesService _personnelRolesService;
@@ -61,6 +63,50 @@ public async Task> GetAllRoles()
return Ok(result);
}
+ ///
+ /// Resolves a role name to the corresponding role.
+ /// Used by the SMTP relay to resolve role dispatch codes like "commander" to numeric role IDs for DispatchList.
+ ///
+ /// The role name to look up.
+ /// Optional department override for SystemApiKey (hosted multi-department) mode.
+ /// RoleResult with the matching role, or a not-found response.
+ [HttpGet("GetRoleByName")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.Role_View)]
+ public async Task> GetRoleByName(string name, [FromQuery] string departmentId = null)
+ {
+ var result = new RoleResult();
+
+ if (String.IsNullOrWhiteSpace(name))
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ var effectiveDepartmentId = GetEffectiveDepartmentId(departmentId);
+
+ if (effectiveDepartmentId <= 0)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ var role = await _personnelRolesService.GetRoleByDepartmentAndNameAsync(effectiveDepartmentId, name);
+
+ if (role == null)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ result.Data = ConvertRoleData(role);
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+
+ return Ok(result);
+ }
+
public static RoleResultData ConvertRoleData(PersonnelRole role)
{
var result = new RoleResultData();
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs
index 8d93a55b..82b4883d 100644
--- a/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs
+++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs
@@ -24,7 +24,8 @@ namespace Resgrid.Web.Services.Controllers.v4
[Route("api/v{VersionId:apiVersion}/[controller]")]
[ApiVersion("4.0")]
[ApiExplorerSettings(GroupName = "v4")]
- public class UnitsController : V4AuthenticatedApiControllerbase
+ [Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")]
+ public class UnitsController : V4AuthenticatedApiControllerbaseSystemAuth
{
#region Members and Constructors
private readonly IUnitsService _unitsService;
@@ -240,6 +241,56 @@ public async Task> GetUnitsFilterOptio
return Ok(result);
}
+ ///
+ /// Resolves a unit name to the corresponding unit.
+ /// Used by the SMTP relay to resolve unit dispatch codes like "engine2" to numeric unit IDs for DispatchList.
+ ///
+ /// The unit name to look up.
+ /// Optional department override for SystemApiKey (hosted multi-department) mode.
+ /// UnitResult with the matching unit, or a not-found response.
+ [HttpGet("GetUnitByName")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = ResgridResources.Unit_View)]
+ public async Task> GetUnitByName(string name, [FromQuery] string departmentId = null)
+ {
+ var result = new UnitResult();
+
+ if (String.IsNullOrWhiteSpace(name))
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ var effectiveDepartmentId = GetEffectiveDepartmentId(departmentId);
+
+ if (effectiveDepartmentId <= 0)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ var unit = await _unitsService.GetUnitByNameDepartmentIdAsync(effectiveDepartmentId, name);
+
+ if (unit == null)
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ if (!IsSystemApiKeyRequest && !await _authorizationService.CanUserViewUnitViaMatrixAsync(unit.UnitId, UserId, DepartmentId))
+ {
+ ResponseHelper.PopulateV4ResponseNotFound(result);
+ return result;
+ }
+
+ result.Data = ConvertUnitsData(unit, null, null, TimeZone);
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Success;
+ ResponseHelper.PopulateV4ResponseData(result);
+
+ return Ok(result);
+ }
+
public static UnitResultData ConvertUnitsData(Model.Unit unit, UnitState state, UnitType type, string timeZone)
{
var data = new UnitResultData();
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/V4AuthenticatedApiControllerbaseSystemAuth.cs b/Web/Resgrid.Web.Services/Controllers/v4/V4AuthenticatedApiControllerbaseSystemAuth.cs
new file mode 100644
index 00000000..4a5b4151
--- /dev/null
+++ b/Web/Resgrid.Web.Services/Controllers/v4/V4AuthenticatedApiControllerbaseSystemAuth.cs
@@ -0,0 +1,80 @@
+using Microsoft.AspNetCore.Mvc;
+using System.Linq;
+using System.Security.Claims;
+using Resgrid.Providers.Claims;
+using Resgrid.Web.ServicesCore.Helpers;
+
+namespace Resgrid.Web.Services.Controllers.v4
+{
+#if (!DEBUG && !DOCKER)
+ //[RequireHttps]
+#endif
+ ///
+ /// Base controller for v4 API endpoints that accept both standard OAuth/OIDC AND SystemApiKey
+ /// authentication (used by the SMTP Relay in hosted multi-department mode).
+ ///
+ /// Controllers that only need standard OAuth should use instead.
+ ///
+ [ApiController]
+ [Produces("application/json")]
+ public class V4AuthenticatedApiControllerbaseSystemAuth : ControllerBase
+ {
+ ///
+ /// Returns the current user ID. In SystemApiKey mode returns a synthetic identifier.
+ ///
+ protected string UserId => IsSystemApiKeyRequest
+ ? "smtp_relay_system"
+ : ClaimsAuthorizationHelper.GetUserId();
+
+ ///
+ /// Returns the department ID from the auth token claims. Callers that need to
+ /// potentially override this with a request-level DepartmentId (SystemApiKey mode)
+ /// should use instead.
+ ///
+ protected int DepartmentId => ClaimsAuthorizationHelper.GetDepartmentId();
+
+ ///
+ /// Returns the current username. In SystemApiKey mode returns "SMTP Relay".
+ ///
+ protected string UserName => IsSystemApiKeyRequest
+ ? "SMTP Relay"
+ : ClaimsAuthorizationHelper.GetUsername();
+
+ protected string TimeZone => ClaimsAuthorizationHelper.GetTimeZone();
+
+ ///
+ /// Returns true if the current request was authenticated via the SystemApiKey scheme
+ /// or carries a service-account marker claim set by the ConnectController client_credentials flow.
+ ///
+ protected bool IsSystemApiKeyRequest =>
+ HttpContext.User.Identities.Any(i => i.AuthenticationType == "SystemApiKey") ||
+ HttpContext.User.HasClaim(ResgridClaimTypes.Data.ServiceAccount, "true");
+
+ ///
+ /// Returns the effective department ID for the current request.
+ /// When a request-level department ID is provided (e.g. from NewCallInput.DepartmentId
+ /// or a departmentId query parameter in SystemApiKey mode), that value takes precedence.
+ /// Otherwise, falls back to the department ID from the auth token claims.
+ ///
+ /// Optional department ID from the request body or query.
+ protected int GetEffectiveDepartmentId(int? requestDepartmentId)
+ {
+ if (IsSystemApiKeyRequest && requestDepartmentId.HasValue && requestDepartmentId.Value > 0)
+ return requestDepartmentId.Value;
+
+ return ClaimsAuthorizationHelper.GetDepartmentId();
+ }
+
+ ///
+ /// Returns the effective department ID for the current request, parsing from a string.
+ ///
+ /// Optional department ID string from the request body or query.
+ protected int GetEffectiveDepartmentId(string requestDepartmentId)
+ {
+ if (IsSystemApiKeyRequest && !string.IsNullOrWhiteSpace(requestDepartmentId) && int.TryParse(requestDepartmentId, out var deptId) && deptId > 0)
+ return deptId;
+
+ return ClaimsAuthorizationHelper.GetDepartmentId();
+ }
+ }
+}
diff --git a/Web/Resgrid.Web.Services/Middleware/SystemApiKeyAuthHandler.cs b/Web/Resgrid.Web.Services/Middleware/SystemApiKeyAuthHandler.cs
new file mode 100644
index 00000000..77215c5b
--- /dev/null
+++ b/Web/Resgrid.Web.Services/Middleware/SystemApiKeyAuthHandler.cs
@@ -0,0 +1,153 @@
+using System;
+using System.Collections.Generic;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Resgrid.Providers.Claims;
+
+namespace Resgrid.Web.Services.Middleware
+{
+ ///
+ /// Authentication handler that validates requests bearing the X-Resgrid-SystemApiKey header.
+ /// Used by the SMTP Relay in hosted multi-department mode to bypass OAuth 2.0 entirely.
+ /// The handler creates a ClaimsPrincipal with full permissions across all departments.
+ /// Department scoping is achieved via the DepartmentId field on individual API request models.
+ ///
+ public class SystemApiKeyAuthHandler : AuthenticationHandler
+ {
+ public SystemApiKeyAuthHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ ISystemClock clock)
+ : base(options, logger, encoder, clock)
+ {
+ }
+
+ protected override Task HandleAuthenticateAsync()
+ {
+ // Skip if the endpoint allows anonymous access
+ var endpoint = Context.GetEndpoint();
+ if (endpoint?.Metadata?.GetMetadata() != null)
+ return Task.FromResult(AuthenticateResult.NoResult());
+
+ // Only process requests that have the X-Resgrid-SystemApiKey header
+ if (!Request.Headers.TryGetValue("X-Resgrid-SystemApiKey", out var apiKeyHeader))
+ return Task.FromResult(AuthenticateResult.NoResult());
+
+ var apiKey = apiKeyHeader.ToString();
+
+ if (string.IsNullOrWhiteSpace(apiKey))
+ return Task.FromResult(AuthenticateResult.NoResult());
+
+ // Validate the API key against the configured system API key (timing-safe comparison)
+ if (string.IsNullOrWhiteSpace(Config.SecurityConfig.SystemApiKey) ||
+ !FixedTimeApiKeyEquals(Config.SecurityConfig.SystemApiKey, apiKey))
+ {
+ return Task.FromResult(AuthenticateResult.Fail("Invalid System API Key"));
+ }
+
+ // Create a ClaimsPrincipal with full permissions
+ var claims = new List
+ {
+ new Claim(ClaimTypes.Name, "SMTP Relay System"),
+ new Claim(ClaimTypes.PrimarySid, "smtp_relay_system"),
+ new Claim(ClaimTypes.PrimaryGroupSid, "0"),
+ new Claim(ClaimTypes.GivenName, "SMTP Relay"),
+ new Claim(ClaimTypes.Email, "smtp-relay@resgrid.local"),
+ // Data claims
+ new Claim(ResgridClaimTypes.Data.TimeZone, "UTC"),
+ new Claim(ResgridClaimTypes.Data.DisplayName, "SMTP Relay"),
+ new Claim(ResgridClaimTypes.Data.UserId, "smtp_relay_system"),
+ new Claim(ResgridClaimTypes.Data.ServiceAccount, "true")
+ };
+
+ // Add all resource claims for full cross-department access
+ var resources = new[]
+ {
+ ResgridClaimTypes.Resources.Department,
+ ResgridClaimTypes.Resources.Personnel,
+ ResgridClaimTypes.Resources.Call,
+ ResgridClaimTypes.Resources.Log,
+ ResgridClaimTypes.Resources.Action,
+ ResgridClaimTypes.Resources.Staffing,
+ ResgridClaimTypes.Resources.Unit,
+ ResgridClaimTypes.Resources.Group,
+ ResgridClaimTypes.Resources.UnitLog,
+ ResgridClaimTypes.Resources.Messages,
+ ResgridClaimTypes.Resources.Role,
+ ResgridClaimTypes.Resources.Profile,
+ ResgridClaimTypes.Resources.Reports,
+ ResgridClaimTypes.Resources.GenericGroup,
+ ResgridClaimTypes.Resources.Documents,
+ ResgridClaimTypes.Resources.Notes,
+ ResgridClaimTypes.Resources.Schedule,
+ ResgridClaimTypes.Resources.Shift,
+ ResgridClaimTypes.Resources.Training,
+ ResgridClaimTypes.Resources.PersonalInfo,
+ ResgridClaimTypes.Resources.Inventory,
+ ResgridClaimTypes.Resources.Command,
+ ResgridClaimTypes.Resources.Connect,
+ ResgridClaimTypes.Resources.Protocols,
+ ResgridClaimTypes.Resources.Forms,
+ ResgridClaimTypes.Resources.Voice,
+ ResgridClaimTypes.Resources.CustomStates,
+ ResgridClaimTypes.Resources.Contacts,
+ ResgridClaimTypes.Resources.Workflow,
+ ResgridClaimTypes.Resources.WorkflowCredential,
+ ResgridClaimTypes.Resources.WorkflowRun,
+ ResgridClaimTypes.Resources.Sso,
+ ResgridClaimTypes.Resources.Scim,
+ ResgridClaimTypes.Resources.Udf,
+ ResgridClaimTypes.Resources.Route,
+ ResgridClaimTypes.Resources.CommunicationTest,
+ ResgridClaimTypes.Resources.WeatherAlert
+ };
+
+ var actions = new[]
+ {
+ ResgridClaimTypes.Actions.View,
+ ResgridClaimTypes.Actions.Create,
+ ResgridClaimTypes.Actions.Update,
+ ResgridClaimTypes.Actions.Delete
+ };
+
+ foreach (var resource in resources)
+ {
+ foreach (var action in actions)
+ {
+ claims.Add(new Claim(resource, action));
+ }
+ }
+
+ var identity = new ClaimsIdentity(claims, "SystemApiKey");
+ var principal = new ClaimsPrincipal(identity);
+ var ticket = new AuthenticationTicket(principal, Scheme.Name);
+
+ return Task.FromResult(AuthenticateResult.Success(ticket));
+ }
+
+ ///
+ /// Performs a timing-safe comparison of two API key strings to prevent timing attacks.
+ /// Unlike string.Equals with Ordinal, this compares every byte regardless of where
+ /// the first mismatch occurs.
+ ///
+ private static bool FixedTimeApiKeyEquals(string stored, string provided)
+ {
+ if (stored == null || provided == null)
+ return false;
+
+ var storedBytes = Encoding.UTF8.GetBytes(stored);
+ var providedBytes = Encoding.UTF8.GetBytes(provided);
+
+ return CryptographicOperations.FixedTimeEquals(storedBytes, providedBytes);
+ }
+ }
+}
diff --git a/Web/Resgrid.Web.Services/Models/v4/CallFiles/SaveCallFileInput.cs b/Web/Resgrid.Web.Services/Models/v4/CallFiles/SaveCallFileInput.cs
index 6f6ca663..fab5d278 100644
--- a/Web/Resgrid.Web.Services/Models/v4/CallFiles/SaveCallFileInput.cs
+++ b/Web/Resgrid.Web.Services/Models/v4/CallFiles/SaveCallFileInput.cs
@@ -42,5 +42,11 @@ public class SaveCallFileInput
public string Longitude { get; set; }
public string Note { get; set; }
+
+ ///
+ /// Department Id for the call file. Only used in System API Key (hosted multi-department) mode.
+ /// When provided, overrides the department derived from the auth token.
+ ///
+ public string DepartmentId { get; set; }
}
}
diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs
index 0e4e7b5e..a46cabe9 100644
--- a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs
+++ b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs
@@ -122,5 +122,11 @@ public class NewCallInput
/// Enable check-in timers for this call. Leave null to use department default.
///
public bool? CheckInTimersEnabled { get; set; }
+
+ ///
+ /// Department Id for the call. Only used in System API Key (hosted multi-department) mode.
+ /// When provided, overrides the department derived from the auth token.
+ ///
+ public string DepartmentId { get; set; }
}
}
diff --git a/Web/Resgrid.Web.Services/Models/v4/Departments/DepartmentResult.cs b/Web/Resgrid.Web.Services/Models/v4/Departments/DepartmentResult.cs
new file mode 100644
index 00000000..4351bbfb
--- /dev/null
+++ b/Web/Resgrid.Web.Services/Models/v4/Departments/DepartmentResult.cs
@@ -0,0 +1,42 @@
+namespace Resgrid.Web.Services.Models.v4.Departments
+{
+ ///
+ /// Result of a department lookup by dispatch email code.
+ ///
+ public class DepartmentResult : StandardApiResponseV4Base
+ {
+ ///
+ /// Response Data
+ ///
+ public DepartmentResultData Data { get; set; }
+
+ ///
+ /// Default constructor
+ ///
+ public DepartmentResult()
+ {
+ Data = new DepartmentResultData();
+ }
+ }
+
+ ///
+ /// The core department information returned by the lookup.
+ ///
+ public class DepartmentResultData
+ {
+ ///
+ /// Id of the department
+ ///
+ public string DepartmentId { get; set; }
+
+ ///
+ /// Name of the department
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// Department code (short identifier)
+ ///
+ public string Code { get; set; }
+ }
+}
diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
index 69c5e286..fa225f9f 100644
--- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
+++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
@@ -244,7 +244,7 @@
Array of CallResult objects for each active call in the department
-
+
Returns a specific call from the Resgrid System
@@ -589,6 +589,17 @@
Falls back to a plain departmentCode name-lookup when departmentToken is absent.
+
+
+ Adds all Resgrid resource claims (View, Create, Update, Delete) to the given identity.
+ Used for system-level and client-credentials service principals that need full access.
+
+
+
+
+ Performs a timing-safe comparison of two secret strings to prevent timing attacks.
+
+
Contacts, which are people, entities, and things that can be contacted (i.e. people, departments, groups, etc.) to dispatch a call to.
@@ -672,6 +683,21 @@
+
+
+ Department-level lookup operations used by external integrations such as the SMTP relay
+ to resolve dispatch codes to departments.
+
+
+
+
+ Resolves a dispatch email code to the department it belongs to.
+ Used by the SMTP relay in single-department or hosted multi-department mode
+ when the department cannot be determined from the email domain alone.
+
+ The dispatch email code (local part of the email address) to look up.
+ DepartmentResult with the matching department, or a not-found response.
+
Mobile or Tablet Device specific operations
@@ -816,6 +842,28 @@
+
+
+ Resolves a group dispatch email code to the corresponding group.
+ Used by the SMTP relay to resolve random dispatch codes (e.g. "XK7M2N") to numeric group IDs for DispatchList.
+ In SystemApiKey (hosted multi-department) mode, departmentId is optional — the code alone resolves to the owning group.
+ In OAuth mode, the group's department is validated against the token's department.
+
+ The dispatch email code (local part of the email address, e.g. a 6-char random string).
+ Optional department scope. In SystemApiKey mode: if provided, validates the group belongs to this department. If omitted, returns any group matching the code. In OAuth mode: ignored, always validates against the token.
+ GroupResult with the matching group (including its DepartmentId), or a not-found response.
+
+
+
+ Resolves a group message email code to the corresponding group.
+ Used by the SMTP relay to resolve random message codes (e.g. "XK7M2N") to numeric group IDs for Message creation.
+ In SystemApiKey (hosted multi-department) mode, departmentId is optional — the code alone resolves to the owning group.
+ In OAuth mode, the group's department is validated against the token's department.
+
+ The message email code (local part of the email address, e.g. a 6-char random string).
+ Optional department scope. In SystemApiKey mode: if provided, validates the group belongs to this department. If omitted, returns any group matching the code. In OAuth mode: ignored, always validates against the token.
+ GroupResult with the matching group (including its DepartmentId), or a not-found response.
+
Call Priorities, for example Low, Medium, High. Call Priorities can be system provided ones or custom for a department
@@ -1207,6 +1255,15 @@
+
+
+ Resolves a role name to the corresponding role.
+ Used by the SMTP relay to resolve role dispatch codes like "commander" to numeric role IDs for DispatchList.
+
+ The role name to look up.
+ Optional department override for SystemApiKey (hosted multi-department) mode.
+ RoleResult with the matching role, or a not-found response.
+
Route planning operations
@@ -1637,6 +1694,15 @@
GetUnitsFilterOptionsResult with information pertaining to each filter option
+
+
+ Resolves a unit name to the corresponding unit.
+ Used by the SMTP relay to resolve unit dispatch codes like "engine2" to numeric unit IDs for DispatchList.
+
+ The unit name to look up.
+ Optional department override for SystemApiKey (hosted multi-department) mode.
+ UnitResult with the matching unit, or a not-found response.
+
Units Status (State) information. For example is the unit Responding to a Call, or Available.
@@ -1693,6 +1759,52 @@
Returns true if the current caller holds a group-admin claim for any group.
+
+
+ Base controller for v4 API endpoints that accept both standard OAuth/OIDC AND SystemApiKey
+ authentication (used by the SMTP Relay in hosted multi-department mode).
+
+ Controllers that only need standard OAuth should use instead.
+
+
+
+
+ Returns the current user ID. In SystemApiKey mode returns a synthetic identifier.
+
+
+
+
+ Returns the department ID from the auth token claims. Callers that need to
+ potentially override this with a request-level DepartmentId (SystemApiKey mode)
+ should use instead.
+
+
+
+
+ Returns the current username. In SystemApiKey mode returns "SMTP Relay".
+
+
+
+
+ Returns true if the current request was authenticated via the SystemApiKey scheme
+ or carries a service-account marker claim set by the ConnectController client_credentials flow.
+
+
+
+
+ Returns the effective department ID for the current request.
+ When a request-level department ID is provided (e.g. from NewCallInput.DepartmentId
+ or a departmentId query parameter in SystemApiKey mode), that value takes precedence.
+ Otherwise, falls back to the department ID from the auth token claims.
+
+ Optional department ID from the request body or query.
+
+
+
+ Returns the effective department ID for the current request, parsing from a string.
+
+ Optional department ID string from the request body or query.
+
Call Priorities, for example Low, Medium, High. Call Priorities can be system provided ones or custom for a department
@@ -3461,6 +3573,52 @@
Is the user a group admin
+
+
+ UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a
+ function that is setting status for the current user.
+
+
+
+
+ The state/staffing level of the user to set for the user.
+
+
+
+
+ Note for the staffing level
+
+
+
+
+ The result object for a state/staffing level request.
+
+
+
+
+ The UserId GUID/UUID for the user state/staffing level being return
+
+
+
+
+ The full name of the user for the state/staffing level being returned
+
+
+
+
+ The current staffing level (state) type for the user
+
+
+
+
+ The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone.
+
+
+
+
+ Staffing note for the User's staffing
+
+
Input data to add a staffing schedule in the Resgrid system
@@ -3566,52 +3724,6 @@
Note for this staffing schedule
-
-
- UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a
- function that is setting status for the current user.
-
-
-
-
- The state/staffing level of the user to set for the user.
-
-
-
-
- Note for the staffing level
-
-
-
-
- The result object for a state/staffing level request.
-
-
-
-
- The UserId GUID/UUID for the user state/staffing level being return
-
-
-
-
- The full name of the user for the state/staffing level being returned
-
-
-
-
- The current staffing level (state) type for the user
-
-
-
-
- The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone.
-
-
-
-
- Staffing note for the User's staffing
-
-
A resrouce in the system this could be a user or unit
@@ -4141,6 +4253,21 @@
The context.
Task.
+
+
+ Authentication handler that validates requests bearing the X-Resgrid-SystemApiKey header.
+ Used by the SMTP Relay in hosted multi-department mode to bypass OAuth 2.0 entirely.
+ The handler creates a ClaimsPrincipal with full permissions across all departments.
+ Department scoping is achieved via the DepartmentId field on individual API request models.
+
+
+
+
+ Performs a timing-safe comparison of two API key strings to prevent timing attacks.
+ Unlike string.Equals with Ordinal, this compares every byte regardless of where
+ the first mismatch occurs.
+
+
Class ValidateCredentialsContext.
@@ -4840,6 +4967,12 @@
Base64 encoded string of the file being uploaded
+
+
+ Department Id for the call file. Only used in System API Key (hosted multi-department) mode.
+ When provided, overrides the department derived from the auth token.
+
+
Gets the notes for a call
@@ -5522,6 +5655,12 @@
Enable check-in timers for this call. Leave null to use department default.
+
+
+ Department Id for the call. Only used in System API Key (hosted multi-department) mode.
+ When provided, overrides the department derived from the auth token.
+
+
Gets the calls current scheduled but not yet dispatched
@@ -6366,6 +6505,41 @@
Is this custom status deleted (only should be used for display)
+
+
+ Result of a department lookup by dispatch email code.
+
+
+
+
+ Response Data
+
+
+
+
+ Default constructor
+
+
+
+
+ The core department information returned by the lookup.
+
+
+
+
+ Id of the department
+
+
+
+
+ Name of the department
+
+
+
+
+ Department code (short identifier)
+
+
Object that contains the device specific information needed to register the device for push notifications
@@ -7334,107 +7508,277 @@
Identifier of the new npte
-
+
- A GPS location for a point in time of a specificed person
+ The result of getting all personnel filters for the system
-
+
- PersonId of the person that the location is for
+ The Id value of the filter
-
+
- The timestamp of the location in UTC
+ The type of the filter
-
+
- GPS Latitude of the Person
+ The filters name
-
+
- GPS Longitude of the Person
+ Result containing all the data required to populate the New Call form
-
+
- GPS Latitude\Longitude Accuracy of the Person
+ Response Data
-
+
- GPS Altitude of the Person
+ Result that contains all the options available to filter personnel against compatible Resgrid APIs
-
+
- GPS Altitude Accuracy of the Person
+ Response Data
-
+
- GPS Speed of the Person
+ Result containing all the data required to populate the New Call form
-
+
- GPS Heading of the Person
+ Response Data
-
+
- A unit location in the Resgrid system
+ Information about a User
-
+
- Response Data
+ The UserId GUID/UUID for the user
-
+
- The information about a specific unit's location
+ DepartmentId of the deparment the user belongs to
-
+
- Id of the Person
+ Department specificed ID number for this user
-
+
- The Timestamp for the location in UTC
+ The Users First Name
-
+
- GPS Latitude of the Person
+ The Users Last Name
-
+
- GPS Longitude of the Person
+ The Users Email Address
-
+
- GPS Latitude\Longitude Accuracy of the Person
+ The Users Mobile Telephone Number
-
+
- GPS Altitude of the Person
+ GroupId the user is assigned to (0 for no group)
-
+
- GPS Altitude Accuracy of the Person
+ Name of the group the user is assigned to
-
+
+
+ Enumeration/List of roles the user currently holds
+
+
+
+
+ The current action/status type for the user
+
+
+
+
+ The current action/status string for the user
+
+
+
+
+ The current action/status color hex string for the user
+
+
+
+
+ The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone.
+
+
+
+
+ The current action/status destination id for the user
+
+
+
+
+ The current action/status destination name for the user
+
+
+
+
+ The current staffing level (state) type for the user
+
+
+
+
+ The current staffing level (state) string for the user
+
+
+
+
+ The current staffing level (state) color hex string for the user
+
+
+
+
+ The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone.
+
+
+
+
+ Users last known location
+
+
+
+
+ Sorting weight for the user
+
+
+
+
+ User Defined Field values for this personnel record
+
+
+
+
+ A GPS location for a point in time of a specificed person
+
+
+
+
+ PersonId of the person that the location is for
+
+
+
+
+ The timestamp of the location in UTC
+
+
+
+
+ GPS Latitude of the Person
+
+
+
+
+ GPS Longitude of the Person
+
+
+
+
+ GPS Latitude\Longitude Accuracy of the Person
+
+
+
+
+ GPS Altitude of the Person
+
+
+
+
+ GPS Altitude Accuracy of the Person
+
+
+
+
+ GPS Speed of the Person
+
+
+
+
+ GPS Heading of the Person
+
+
+
+
+ A unit location in the Resgrid system
+
+
+
+
+ Response Data
+
+
+
+
+ The information about a specific unit's location
+
+
+
+
+ Id of the Person
+
+
+
+
+ The Timestamp for the location in UTC
+
+
+
+
+ GPS Latitude of the Person
+
+
+
+
+ GPS Longitude of the Person
+
+
+
+
+ GPS Latitude\Longitude Accuracy of the Person
+
+
+
+
+ GPS Altitude of the Person
+
+
+
+
+ GPS Altitude Accuracy of the Person
+
+
+
GPS Speed of the Person
@@ -7837,282 +8181,112 @@
Response Data
-
+
- The result of getting all personnel filters for the system
+ Result containing all the data required to populate the New Call form
-
+
- The Id value of the filter
+ Response Data
-
+
- The type of the filter
+ Details of a protocol
-
+
- The filters name
+ Protocol id
-
+
- Result containing all the data required to populate the New Call form
+ Department id
-
+
- Response Data
+ Name of the Protocol
-
+
- Result that contains all the options available to filter personnel against compatible Resgrid APIs
+ Protocol code
-
+
- Response Data
+ This this protocol disabled
-
+
- Result containing all the data required to populate the New Call form
+ Protocol description
-
+
- Response Data
+ Text of the protocol
-
+
- Information about a User
+ UTC date and time when the Protocol was created
-
+
- The UserId GUID/UUID for the user
+ UserId of the user who created the protocol
-
+
- DepartmentId of the deparment the user belongs to
+ UTC timestamp of when the Protocol was updated
-
+
- Department specificed ID number for this user
+ Minimum triggering Weight of the Protocol
-
+
- The Users First Name
+ UserId that last updated the Protocol
-
+
- The Users Last Name
+ Triggers used to activate this Protocol
-
+
- The Users Email Address
+ Attachments for this Protocol
-
+
- The Users Mobile Telephone Number
+ Questions used to determine if this Protocol needs to be used or not
-
+
- GroupId the user is assigned to (0 for no group)
+ State type
-
+
- Name of the group the user is assigned to
+ Result containing all the data required to populate the New Call form
-
+
- Enumeration/List of roles the user currently holds
+ Response Data
-
-
- The current action/status type for the user
-
-
-
-
- The current action/status string for the user
-
-
-
-
- The current action/status color hex string for the user
-
-
-
-
- The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone.
-
-
-
-
- The current action/status destination id for the user
-
-
-
-
- The current action/status destination name for the user
-
-
-
-
- The current staffing level (state) type for the user
-
-
-
-
- The current staffing level (state) string for the user
-
-
-
-
- The current staffing level (state) color hex string for the user
-
-
-
-
- The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone.
-
-
-
-
- Users last known location
-
-
-
-
- Sorting weight for the user
-
-
-
-
- User Defined Field values for this personnel record
-
-
-
-
- Result containing all the data required to populate the New Call form
-
-
-
-
- Response Data
-
-
-
-
- Details of a protocol
-
-
-
-
- Protocol id
-
-
-
-
- Department id
-
-
-
-
- Name of the Protocol
-
-
-
-
- Protocol code
-
-
-
-
- This this protocol disabled
-
-
-
-
- Protocol description
-
-
-
-
- Text of the protocol
-
-
-
-
- UTC date and time when the Protocol was created
-
-
-
-
- UserId of the user who created the protocol
-
-
-
-
- UTC timestamp of when the Protocol was updated
-
-
-
-
- Minimum triggering Weight of the Protocol
-
-
-
-
- UserId that last updated the Protocol
-
-
-
-
- Triggers used to activate this Protocol
-
-
-
-
- Attachments for this Protocol
-
-
-
-
- Questions used to determine if this Protocol needs to be used or not
-
-
-
-
- State type
-
-
-
-
- Result containing all the data required to populate the New Call form
-
-
-
-
- Response Data
-
-
-
+
A role in the Resgrid system
@@ -9306,545 +9480,545 @@
Default constructor
-
+
- Depicts a result after saving a unit status
+ Result that contains all the options available to filter units against compatible Resgrid APIs
-
+
Response Data
-
+
- Object inputs for setting a users Status/Action. If this object is used in an operation that sets
- a status for the current user the UserId value in this object will be ignored.
+ A unit in the Resgrid system
-
+
- UnitId of the apparatus that the state is being set for
+ Response Data
-
+
- The UnitStateType of the Unit
+ The information about a specific unit
-
+
- The Call/Station the unit is responding to
+ Id of the Unit
-
+
- Destination type for RespondingTo (Station = 1, Call = 2, POI = 3).
+ The Id of the department the unit is under
-
+
- The timestamp of the status event in UTC
+ Name of the Unit
-
+
- The timestamp of the status event in the local time of the device
+ Department assigned type for the unit
-
+
- User provided note for this event
+ Department assigned type id for the unit
-
+
- GPS Latitude of the Unit
+ Custom Statuses Set Id
-
+
- GPS Longitude of the Unit
+ Station Id of the station housing the unit (0 means no station)
-
+
- GPS Latitude\Longitude Accuracy of the Unit
+ Name of the station the unit is under
-
+
- GPS Altitude of the Unit
+ Vehicle Identification Number for the unit
-
+
- GPS Altitude Accuracy of the Unit
+ Plate Number for the Unit
-
+
- GPS Speed of the Unit
+ Is the unit 4-Wheel drive
-
+
- GPS Heading of the Unit
+ Does the unit require a special permit to drive
-
+
- The event id used for queuing on mobile applications
+ Id number of the units current destionation (0 means no destination)
-
+
- The accountability roles filed for this event
+ The current status/state of the Unit
-
+
- Role filled by a User on a Unit for an event
+ The Timestamp of the status
-
+
- Id of the locally stored event
+ The units current Latitude
-
+
- Local Event Id
+ The units current Longitude
-
+
- UserId of the user filling the role
+ Current user provide status note
-
+
- RoleId of the role being filled
+ User Defined Field values for this unit
-
+
- The name of the Role
+ Unit role information for roles on a unit
-
+
- Depicts a unit status in the Resgrid system.
+ Unit Role Id
-
+
- Response Data
+ User Id of the user in the role (could be null)
-
+
- Depicts a unit's status
+ Name of the Role
-
+
- Unit Id
+ Name of the user in the role (could be null)
-
+
- Units Name
+ Multiple Unit infos Result
-
+
- The Type of the Unit
+ Response Data
-
+
- Units current Status (State)
+ Default constructor
-
+
- CSS for status (for display)
+ The information about a specific unit
-
+
- CSS Style for status (for display)
+ Id of the Unit
-
+
- Timestamp of this Unit State
+ The Id of the department the unit is under
-
+
- Timestamp in Utc of this Unit State
+ Name of the Unit
-
+
- Destination Id (Station or Call)
+ Department assigned type for the unit
-
+
- Destination type (Station, Call, or POI).
+ Department assigned type id for the unit
-
+
- Name of the Desination (Call or Station)
+ Custom Statuses Set Id
-
+
- Destination address.
+ Station Id of the station housing the unit (0 means no station)
-
+
- Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not
- suitable for programmatic branching; use as the
- machine-readable discriminator instead.
+ Name of the station the unit is under
-
+
- Note for the State
+ Vehicle Identification Number for the unit
-
+
- Latitude
+ Plate Number for the Unit
-
+
- Longitude
+ Is the unit 4-Wheel drive
-
+
- Name of the Group the Unit is in
+ Does the unit require a special permit to drive
-
+
- Id of the Group the Unit is in
+ Id number of the units current destination (0 means no destination)
-
+
- Unit statuses (states)
+ Name of the units current destination (0 means no destination)
-
+
- Response Data
+ The current status/state of the Unit
-
+
- Default constructor
+ The current status/state of the Unit as a name
-
+
- Result that contains all the options available to filter units against compatible Resgrid APIs
+ The current status/state of the Unit color
-
+
- Response Data
+ The Timestamp of the status
-
+
- A unit in the Resgrid system
+ The Timestamp of the status in UTC/GMT
-
+
- Response Data
+ The units current Latitude
-
+
- The information about a specific unit
+ The units current Longitude
-
+
- Id of the Unit
+ Current user provide status note
-
+
- The Id of the department the unit is under
+ Units Roles
-
+
- Name of the Unit
+ Multiple Units Result
-
+
- Department assigned type for the unit
+ Response Data
-
+
- Department assigned type id for the unit
+ Default constructor
-
+
- Custom Statuses Set Id
+ Depicts a result after saving a unit status
-
+
- Station Id of the station housing the unit (0 means no station)
+ Response Data
-
+
- Name of the station the unit is under
+ Object inputs for setting a users Status/Action. If this object is used in an operation that sets
+ a status for the current user the UserId value in this object will be ignored.
-
+
- Vehicle Identification Number for the unit
+ UnitId of the apparatus that the state is being set for
-
+
- Plate Number for the Unit
+ The UnitStateType of the Unit
-
+
- Is the unit 4-Wheel drive
+ The Call/Station the unit is responding to
-
+
- Does the unit require a special permit to drive
+ Destination type for RespondingTo (Station = 1, Call = 2, POI = 3).
-
+
- Id number of the units current destionation (0 means no destination)
+ The timestamp of the status event in UTC
-
+
- The current status/state of the Unit
+ The timestamp of the status event in the local time of the device
-
+
- The Timestamp of the status
+ User provided note for this event
-
+
- The units current Latitude
+ GPS Latitude of the Unit
-
+
- The units current Longitude
+ GPS Longitude of the Unit
-
+
- Current user provide status note
+ GPS Latitude\Longitude Accuracy of the Unit
-
+
- User Defined Field values for this unit
+ GPS Altitude of the Unit
-
+
- Unit role information for roles on a unit
+ GPS Altitude Accuracy of the Unit
-
+
- Unit Role Id
+ GPS Speed of the Unit
-
+
- User Id of the user in the role (could be null)
+ GPS Heading of the Unit
-
+
- Name of the Role
+ The event id used for queuing on mobile applications
-
+
- Name of the user in the role (could be null)
+ The accountability roles filed for this event
-
+
- Multiple Unit infos Result
+ Role filled by a User on a Unit for an event
-
+
- Response Data
+ Id of the locally stored event
-
+
- Default constructor
+ Local Event Id
-
+
- The information about a specific unit
+ UserId of the user filling the role
-
+
- Id of the Unit
+ RoleId of the role being filled
-
+
- The Id of the department the unit is under
+ The name of the Role
-
+
- Name of the Unit
+ Depicts a unit status in the Resgrid system.
-
+
- Department assigned type for the unit
+ Response Data
-
+
- Department assigned type id for the unit
+ Depicts a unit's status
-
+
- Custom Statuses Set Id
+ Unit Id
-
+
- Station Id of the station housing the unit (0 means no station)
+ Units Name
-
+
- Name of the station the unit is under
+ The Type of the Unit
-
+
- Vehicle Identification Number for the unit
+ Units current Status (State)
-
+
- Plate Number for the Unit
+ CSS for status (for display)
-
+
- Is the unit 4-Wheel drive
+ CSS Style for status (for display)
-
+
- Does the unit require a special permit to drive
+ Timestamp of this Unit State
-
+
- Id number of the units current destination (0 means no destination)
+ Timestamp in Utc of this Unit State
-
+
- Name of the units current destination (0 means no destination)
+ Destination Id (Station or Call)
-
+
- The current status/state of the Unit
+ Destination type (Station, Call, or POI).
-
+
- The current status/state of the Unit as a name
+ Name of the Desination (Call or Station)
-
+
- The current status/state of the Unit color
+ Destination address.
-
+
- The Timestamp of the status
+ Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not
+ suitable for programmatic branching; use as the
+ machine-readable discriminator instead.
-
+
- The Timestamp of the status in UTC/GMT
+ Note for the State
-
+
- The units current Latitude
+ Latitude
-
+
- The units current Longitude
+ Longitude
-
+
- Current user provide status note
+ Name of the Group the Unit is in
-
+
- Units Roles
+ Id of the Group the Unit is in
-
+
- Multiple Units Result
+ Unit statuses (states)
-
+
Response Data
-
+
Default constructor
diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs
index 9d533c46..2880afac 100644
--- a/Web/Resgrid.Web.Services/Startup.cs
+++ b/Web/Resgrid.Web.Services/Startup.cs
@@ -47,6 +47,7 @@
//using OpenTelemetry.Metrics;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using System.Security.Cryptography.X509Certificates;
+using Microsoft.AspNetCore.Authentication;
using Sentry.Extensibility;
using Resgrid.Web.ServicesCore.Middleware;
using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
@@ -593,7 +594,8 @@ public void ConfigureServices(IServiceCollection services)
services.AddAuthentication("BasicAuthentication")
- .AddScheme("BasicAuthentication", null);
+ .AddScheme("BasicAuthentication", null)
+ .AddScheme("SystemApiKey", null);
//// TODO: Add IServiceCollection.AddOpenTelemetryMetrics extension method
//var providerBuilder = Sdk.CreateMeterProviderBuilder()