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()