From a384e3edae0166414a58cfd02f72d532fb23760b Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 24 Jul 2026 14:46:54 -0700 Subject: [PATCH 1/8] RG-T127 Hardware unit tracking support --- .coderabbit.yaml | 1 + Core/Resgrid.Config/ServiceBusConfig.cs | 3 + Core/Resgrid.Config/UnitTrackingConfig.cs | 46 ++ .../SentryTransactionFilter.cs | 28 +- .../Areas/User/Units/Units.resx | 237 ++++++ Core/Resgrid.Model/AuditLogTypes.cs | 10 +- Core/Resgrid.Model/DepartmentSettingTypes.cs | 3 + .../Resgrid.Model/Events/UnitLocationEvent.cs | 47 ++ .../Providers/IEventAggregator.cs | 3 + .../Providers/IRabbitOutboundQueueProvider.cs | 5 + .../Providers/IUnitLocationEventProvider.cs | 5 + .../Repositories/IDocumentDbRepository.cs | 2 +- .../IUnitLocationsDocRepository.cs | 4 +- .../IUnitLocationsMongoRepository.cs | 11 + .../IUnitTrackingCredentialsRepository.cs | 11 + .../IUnitTrackingDevicesRepository.cs | 11 + .../Services/IDepartmentSettingsService.cs | 6 + .../Services/IUnitLocationSourceResolver.cs | 14 + .../IUnitTrackingAuthenticationService.cs | 31 + .../Services/IUnitTrackingCatalogService.cs | 17 + .../Services/IUnitTrackingEventIdService.cs | 7 + .../IUnitTrackingIdentifierService.cs | 8 + .../Services/IUnitTrackingIngressService.cs | 15 + .../Services/IUnitTrackingService.cs | 62 ++ .../Services/IUnitTrackingStatusService.cs | 14 + Core/Resgrid.Model/Services/IUnitsService.cs | 4 +- .../Tracking/AuthenticatedTrackingSource.cs | 9 + .../Tracking/CanonicalTrackingPosition.cs | 27 + .../Tracking/TrackingIngressResult.cs | 21 + .../Tracking/UnitTrackingCatalogProfile.cs | 23 + Core/Resgrid.Model/UnitLocationWriteResult.cs | 32 + Core/Resgrid.Model/UnitTrackingCredential.cs | 75 ++ Core/Resgrid.Model/UnitTrackingDevice.cs | 104 +++ Core/Resgrid.Model/UnitTrackingEnums.cs | 53 ++ Core/Resgrid.Model/UnitTrackingResults.cs | 31 + Core/Resgrid.Model/UnitsLocation.cs | 52 ++ Core/Resgrid.Services/AuditService.cs | 14 + .../DepartmentSettingsService.cs | 157 +++- Core/Resgrid.Services/ServicesModule.cs | 8 + .../UnitLocationSourceResolver.cs | 97 +++ .../UnitTrackingAuthenticationService.cs | 283 +++++++ .../Resgrid.Services/UnitTrackingCacheKeys.cs | 30 + .../UnitTrackingCatalogService.cs | 83 ++ .../UnitTrackingEventIdService.cs | 32 + .../UnitTrackingIdentifierService.cs | 41 + .../UnitTrackingIngressService.cs | 379 ++++++++++ Core/Resgrid.Services/UnitTrackingService.cs | 713 ++++++++++++++++++ .../UnitTrackingStatusService.cs | 50 ++ Core/Resgrid.Services/UnitsService.cs | 60 +- .../RabbitConnection.cs | 45 ++ .../RabbitInboundQueueProvider.cs | 163 +++- .../RabbitOutboundQueueProvider.cs | 114 ++- .../RabbitTopicProvider.cs | 45 +- Providers/Resgrid.Providers.Bus/BusModule.cs | 1 + .../Resgrid.Providers.Bus/EventAggregator.cs | 33 + .../OutboundEventProvider.cs | 7 +- .../UnitLocationEventProvider.cs | 23 +- .../Migrations/M0101_AddUnitTracking.cs | 144 ++++ .../Migrations/M0101_AddUnitTrackingPg.cs | 137 ++++ .../Sql/EF0003_PopulateDocDb.sql | 24 + .../Modules/DataModule.cs | 2 + ...UnitTrackingCredentialBySecretHashQuery.cs | 31 + ...ectUnitTrackingCredentialsByDeviceQuery.cs | 33 + ...TrackingDeviceByProtocolIdentifierQuery.cs | 37 + .../SelectUnitTrackingDevicesByUnitQuery.cs | 35 + .../UnitTrackingCredentialsRepository.cs | 83 ++ .../UnitTrackingDevicesRepository.cs | 85 +++ .../DocumentDbRepository.cs | 13 + .../NoSqlDataModule.cs | 1 + .../UnitLocationsDocRepository.cs | 58 +- .../UnitLocationsMongoRepository.cs | 97 +++ .../Framework/SentryTransactionFilterTests.cs | 12 + .../UnitLocationEventSerializationTests.cs | 125 +++ .../Providers/EventAggregatorTests.cs | 40 + .../UnitLocationEventProviderTests.cs | 63 ++ .../Repositories/DocumentDbRepositoryTests.cs | 46 ++ Tests/Resgrid.Tests/Resgrid.Tests.csproj | 2 + ...artmentSettingsServiceUnitTrackingTests.cs | 95 +++ .../DocumentDatabaseProviderSelectionTests.cs | 74 +- .../UnitLocationSourceResolverTests.cs | 134 ++++ .../UnitTrackingAuthenticationServiceTests.cs | 120 +++ .../UnitTrackingCatalogServiceTests.cs | 52 ++ .../UnitTrackingEventIdServiceTests.cs | 24 + .../UnitTrackingIdentifierServiceTests.cs | 43 ++ .../UnitTrackingIngressServiceTests.cs | 257 +++++++ .../Services/UnitTrackingServiceTests.cs | 393 ++++++++++ .../UnitTrackingStatusServiceTests.cs | 119 +++ .../CapabilityPathRedactionMiddlewareTests.cs | 65 ++ .../Services/UnitLocationControllerTests.cs | 78 ++ .../UnitTrackingDevicesControllerTests.cs | 359 +++++++++ ...tTrackingHttpAuthenticationServiceTests.cs | 144 ++++ .../UnitTrackingIngressControllerTests.cs | 316 ++++++++ .../UnitTrackingJsonPayloadParserTests.cs | 161 ++++ .../UnitTrackingNetworkPolicyTests.cs | 30 + .../Services/UnitTrackingRateLimiterTests.cs | 56 ++ .../Web/User/UnitTrackingControllerTests.cs | 338 +++++++++ .../UnitTrackingHttpAuthenticationService.cs | 231 ++++++ .../UnitTrackingJsonPayloadParser.cs | 312 ++++++++ .../UnitTracking/UnitTrackingNetworkPolicy.cs | 72 ++ .../UnitTracking/UnitTrackingRateLimiter.cs | 122 +++ .../Controllers/v4/UnitLocationController.cs | 4 +- .../v4/UnitTrackingDevicesController.cs | 599 +++++++++++++++ .../v4/UnitTrackingIngressController.cs | 241 ++++++ .../CapabilityPathRedactionMiddleware.cs | 63 ++ .../Middleware/SentryEventProcessor.cs | 4 + .../UnitTracking/UnitTrackingAdminModels.cs | 153 ++++ .../UnitTrackingIngressResponse.cs | 16 + .../UnitTracking/UnitTrackingPositionInput.cs | 71 ++ Web/Resgrid.Web.Services/Program.cs | 5 + Web/Resgrid.Web.Services/Startup.cs | 5 + .../Controllers/UnitTrackingController.cs | 649 ++++++++++++++++ .../UnitTracking/UnitTrackingViewModels.cs | 117 +++ .../User/Views/UnitTracking/Credential.cshtml | 91 +++ .../User/Views/UnitTracking/Details.cshtml | 232 ++++++ .../Areas/User/Views/UnitTracking/Edit.cshtml | 24 + .../User/Views/UnitTracking/Index.cshtml | 88 +++ .../Areas/User/Views/UnitTracking/New.cshtml | 24 + .../User/Views/UnitTracking/_Editor.cshtml | 92 +++ .../Areas/User/Views/Units/EditUnit.cshtml | 7 + .../Logic/UnitLocationQueueLogic.cs | 85 ++- 120 files changed, 10451 insertions(+), 192 deletions(-) create mode 100644 Core/Resgrid.Config/UnitTrackingConfig.cs create mode 100644 Core/Resgrid.Localization/Areas/User/Units/Units.resx create mode 100644 Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IUnitTrackingCredentialsRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IUnitTrackingDevicesRepository.cs create mode 100644 Core/Resgrid.Model/Services/IUnitLocationSourceResolver.cs create mode 100644 Core/Resgrid.Model/Services/IUnitTrackingAuthenticationService.cs create mode 100644 Core/Resgrid.Model/Services/IUnitTrackingCatalogService.cs create mode 100644 Core/Resgrid.Model/Services/IUnitTrackingEventIdService.cs create mode 100644 Core/Resgrid.Model/Services/IUnitTrackingIdentifierService.cs create mode 100644 Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs create mode 100644 Core/Resgrid.Model/Services/IUnitTrackingService.cs create mode 100644 Core/Resgrid.Model/Services/IUnitTrackingStatusService.cs create mode 100644 Core/Resgrid.Model/Tracking/AuthenticatedTrackingSource.cs create mode 100644 Core/Resgrid.Model/Tracking/CanonicalTrackingPosition.cs create mode 100644 Core/Resgrid.Model/Tracking/TrackingIngressResult.cs create mode 100644 Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs create mode 100644 Core/Resgrid.Model/UnitLocationWriteResult.cs create mode 100644 Core/Resgrid.Model/UnitTrackingCredential.cs create mode 100644 Core/Resgrid.Model/UnitTrackingDevice.cs create mode 100644 Core/Resgrid.Model/UnitTrackingEnums.cs create mode 100644 Core/Resgrid.Model/UnitTrackingResults.cs create mode 100644 Core/Resgrid.Services/UnitLocationSourceResolver.cs create mode 100644 Core/Resgrid.Services/UnitTrackingAuthenticationService.cs create mode 100644 Core/Resgrid.Services/UnitTrackingCacheKeys.cs create mode 100644 Core/Resgrid.Services/UnitTrackingCatalogService.cs create mode 100644 Core/Resgrid.Services/UnitTrackingEventIdService.cs create mode 100644 Core/Resgrid.Services/UnitTrackingIdentifierService.cs create mode 100644 Core/Resgrid.Services/UnitTrackingIngressService.cs create mode 100644 Core/Resgrid.Services/UnitTrackingService.cs create mode 100644 Core/Resgrid.Services/UnitTrackingStatusService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0101_AddUnitTracking.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingCredentialBySecretHashQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingCredentialsByDeviceQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingDeviceByProtocolIdentifierQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingDevicesByUnitQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/UnitTrackingCredentialsRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/UnitTrackingDevicesRepository.cs create mode 100644 Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs create mode 100644 Tests/Resgrid.Tests/Framework/UnitLocationEventSerializationTests.cs create mode 100644 Tests/Resgrid.Tests/Providers/EventAggregatorTests.cs create mode 100644 Tests/Resgrid.Tests/Providers/UnitLocationEventProviderTests.cs create mode 100644 Tests/Resgrid.Tests/Repositories/DocumentDbRepositoryTests.cs create mode 100644 Tests/Resgrid.Tests/Services/DepartmentSettingsServiceUnitTrackingTests.cs create mode 100644 Tests/Resgrid.Tests/Services/UnitLocationSourceResolverTests.cs create mode 100644 Tests/Resgrid.Tests/Services/UnitTrackingAuthenticationServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/UnitTrackingEventIdServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/UnitTrackingIdentifierServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/UnitTrackingIngressServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/UnitTrackingServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/UnitTrackingStatusServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/CapabilityPathRedactionMiddlewareTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/UnitLocationControllerTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/UnitTrackingDevicesControllerTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/UnitTrackingHttpAuthenticationServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/UnitTrackingIngressControllerTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/UnitTrackingJsonPayloadParserTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/UnitTrackingNetworkPolicyTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/UnitTrackingRateLimiterTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/UnitTrackingControllerTests.cs create mode 100644 Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingHttpAuthenticationService.cs create mode 100644 Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingJsonPayloadParser.cs create mode 100644 Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingNetworkPolicy.cs create mode 100644 Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingRateLimiter.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingDevicesController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingIngressController.cs create mode 100644 Web/Resgrid.Web.Services/Middleware/CapabilityPathRedactionMiddleware.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingAdminModels.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingIngressResponse.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingPositionInput.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/UnitTrackingController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/UnitTracking/UnitTrackingViewModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/UnitTracking/Credential.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/UnitTracking/Details.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/UnitTracking/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/UnitTracking/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/UnitTracking/New.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/UnitTracking/_Editor.cshtml diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 2d5c0aeaa..6b4aeb317 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -39,6 +39,7 @@ reviews: - "!**/obj/**" - "!**/Tests/**" - "!**/.claude/**" + - "!**/.agent/**" - "!**/*.md" path_instructions: [] abort_on_close: true diff --git a/Core/Resgrid.Config/ServiceBusConfig.cs b/Core/Resgrid.Config/ServiceBusConfig.cs index 7aaf97ec0..9e164fb85 100644 --- a/Core/Resgrid.Config/ServiceBusConfig.cs +++ b/Core/Resgrid.Config/ServiceBusConfig.cs @@ -50,6 +50,9 @@ public static class ServiceBusConfig public static string RabbitUsername = ""; public static string RabbbitPassword = ""; public static string RabbbitExchange = ""; + public static string UnitLocationQueueV2Name = "unitlocation-v2"; + public static string UnitLocationRetryQueueV2Name = "unitlocation-v2.retry"; + public static string UnitLocationDeadQueueV2Name = "unitlocation-v2.dead"; #endregion RabbitMQ Bus Values } diff --git a/Core/Resgrid.Config/UnitTrackingConfig.cs b/Core/Resgrid.Config/UnitTrackingConfig.cs new file mode 100644 index 000000000..dbc8ad946 --- /dev/null +++ b/Core/Resgrid.Config/UnitTrackingConfig.cs @@ -0,0 +1,46 @@ +namespace Resgrid.Config +{ + public static class UnitTrackingConfig + { + public static bool Enabled = false; + public static bool HttpsIngressEnabled = false; + public static bool NativeGatewayEnabled = false; + public static string CredentialPepper = ""; + public static string PublicHttpsBaseUrl = ""; + public static int MaxRequestBytes = 262144; + public static int MaxBatchPositions = 100; + public static int MaxJsonDepth = 16; + public static int MaxFutureSkewSeconds = 300; + public static int DefaultLocationRetentionDays = 90; + public static int MinimumLocationRetentionDays = 1; + public static int MaximumLocationRetentionDays = 3650; + public static int PerDeviceRequestsPerMinute = 120; + public static int PerDeviceRecordsPerMinute = 1200; + public static int UnknownEndpointRequestsPerMinute = 30; + public static int CredentialCacheSeconds = 60; + public static int CredentialRotationOverlapHours = 24; + public static int DeviceMappingCacheSeconds = 300; + public static int UnknownDeviceCacheSeconds = 30; + public static int QueueMessageTtlSeconds = 86400; + public static int QueuePublishTimeoutSeconds = 5; + public static int UnitLocationQueuePrefetchCount = 25; + public static int UnitLocationRetryDelaySeconds = 30; + public static int UnitLocationMaxRetryAttempts = 3; + public static int TcpIdleTimeoutSeconds = 300; + public static int MaxFrameBytes = 65536; + public static int MaxConnections = 5000; + public static int MaxConnectionsPerIp = 100; + public static int GracefulShutdownSeconds = 30; + public static int InternalHealthPort = 8080; + public static int QueclinkTcpPort = 5004; + public static int QueclinkUdpPort = 5004; + public static int Gt06TcpPort = 5023; + public static int Gt06UdpPort = 5023; + public static int TeltonikaTcpPort = 5027; + public static int TeltonikaUdpPort = 5027; + public static bool EnableQueclink = false; + public static bool EnableGt06 = false; + public static bool EnableTeltonika = false; + public static bool RawDiagnosticCaptureEnabled = false; + } +} diff --git a/Core/Resgrid.Framework/SentryTransactionFilter.cs b/Core/Resgrid.Framework/SentryTransactionFilter.cs index d0e1ede9e..833fbc4ac 100644 --- a/Core/Resgrid.Framework/SentryTransactionFilter.cs +++ b/Core/Resgrid.Framework/SentryTransactionFilter.cs @@ -61,7 +61,13 @@ public static class SentryTransactionFilter /// public static SentryTransaction Filter(SentryTransaction transaction) { - if (transaction == null || transaction.Status != SpanStatus.NotFound) + if (transaction == null) + return null; + + if (transaction.Request != null) + transaction.Request.Url = RedactCapabilityPath(transaction.Request.Url); + + if (transaction.Status != SpanStatus.NotFound) return transaction; var requestTarget = transaction.Request?.Url; @@ -71,6 +77,26 @@ public static SentryTransaction Filter(SentryTransaction transaction) return ShouldDrop(transaction.Status, requestTarget) ? null : transaction; } + public static string RedactCapabilityPath(string value) + { + const string prefix = "/api/v4/unit-trackers/c/"; + if (string.IsNullOrWhiteSpace(value)) + return value; + + var start = value.IndexOf(prefix, StringComparison.OrdinalIgnoreCase); + if (start < 0) + return value; + + var tokenStart = start + prefix.Length; + var tokenEnd = value.IndexOfAny(new[] { '?', '#', '/' }, tokenStart); + if (tokenEnd < 0) + tokenEnd = value.Length; + + return value.Substring(0, tokenStart) + + "[REDACTED]" + + value.Substring(tokenEnd); + } + public static bool ShouldDrop(SpanStatus? status, string requestTarget) { return status == SpanStatus.NotFound && IsKnownScannerPath(requestTarget); diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.resx new file mode 100644 index 000000000..972a6edb4 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Units/Units.resx @@ -0,0 +1,237 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Add tracking binding + + + Allowed source networks + + + Optional comma-separated IPv4 or IPv6 CIDR ranges permitted to send positions for this tracker. + + + Authentication mode + + + Basic authentication username + + + A Basic authentication username is required. + + + Certification status + + + Copy + + + Create credential + + + Credential prefix + + + I have saved this credential + + + Credentials + + + Credential token + + + A custom header name is required. + + + Remove this tracking binding? Its credentials will be revoked and it will stop accepting positions. + + + Device identifier + + + Use the identifier emitted by the device or forwarding service. It is normalized before storage. + + + Disable tracking + + + Disable this binding and revoke all of its credentials? + + + Edit tracking binding + + + HTTPS endpoint + + + Expires + + + Firmware version + + + Hardware GPS tracking + + + Bind a hardware tracker or forwarding service to this Unit, issue credentials, and monitor delivery health. + + + Header name + + + Header value + + + Last error code + + + Last received + + + Last seen + + + Last used + + + Last valid fix + + + JSON payload + + + Never + + + No credentials have been issued for this binding. + + + No hardware tracking bindings are configured for this Unit. + + + Save tracking credential + + + This credential is shown only once. Copy the endpoint and secret into the tracker configuration before leaving this page. + + + Protocol + + + Validate test JSON + + + The JSON payload is malformed, contains duplicate fields, or exceeds the nesting limit. + + + Each position requires a non-empty eventId and valid latitude and longitude values. + + + A batch must contain at least one JSON position object. + + + Enter a JSON payload to validate. + + + The preview parser accepted {0} position(s). Nothing was queued or stored. + + + The JSON payload exceeds the configured request size limit. + + + The JSON batch exceeds the configured position limit. + + + Revoke + + + Revoke this credential? The sender will no longer be able to use it. + + + Retry expectation: + + + Revoked + + + Rotate + + + Save tracking binding + + + Send test JSON + + + Validate a generic Resgrid JSON payload without authenticating, queueing, or storing it. This preview is available only to department administrators outside production. + + + Setup instructions + + + Secondary identifier + + + Select a certified tracking profile + + + Select a certified tracking profile. + + + Source priority + + + Tracking binding actions + + + Tracking binding created. Generate a credential to finish setup. + + + Tracking binding removed. + + + Tracking binding disabled and its credentials revoked. + + + Tracking binding updated. + + + Tracking credential revoked. + + + Display name + + + Enabled + + + A device identifier is required for the selected profile. + + + Tracking profile + + + Tracking status + + + Transport + + + The selected authentication mode is not supported by this tracking profile. + + + View tracking status + + diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index 20ee009e3..d3149b49e 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -163,6 +163,14 @@ public enum AuditLogTypes WeatherAlertSettingsChanged, // Feature Toggles FeatureFlagChanged, - FeatureFlagOverrideChanged + FeatureFlagOverrideChanged, + // Unit hardware tracking + UnitTrackingDeviceCreated, + UnitTrackingDeviceUpdated, + UnitTrackingDeviceDisabled, + UnitTrackingDeviceDeleted, + UnitTrackingCredentialCreated, + UnitTrackingCredentialRotated, + UnitTrackingCredentialRevoked } } diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs index d747393f8..8fdfe3522 100644 --- a/Core/Resgrid.Model/DepartmentSettingTypes.cs +++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs @@ -56,5 +56,8 @@ public enum DepartmentSettingTypes UnitCallStatusOverridesByUnitType = 52, EnableModernNotifications = 53, ForceChatbotSecurityPin = 54, + HardwareTrackingStaleAfterSeconds = 55, + HardwareTrackingMobileFallbackEnabled = 56, + HardwareTrackingLocationRetentionDays = 57, } } diff --git a/Core/Resgrid.Model/Events/UnitLocationEvent.cs b/Core/Resgrid.Model/Events/UnitLocationEvent.cs index fff6d0b50..d1f8d91c7 100644 --- a/Core/Resgrid.Model/Events/UnitLocationEvent.cs +++ b/Core/Resgrid.Model/Events/UnitLocationEvent.cs @@ -42,6 +42,53 @@ public class UnitLocationEvent [ProtoMember(12)] public decimal? Heading { get; set; } + [ProtoMember(13)] + public DateTime? ReceivedOn { get; set; } + + [ProtoMember(14)] + public int SourceType { get; set; } + + [ProtoMember(15)] + public string SourceId { get; set; } + + [ProtoMember(16)] + public int SourcePriority { get; set; } + + [ProtoMember(17)] + public int? TransportType { get; set; } + + [ProtoMember(18)] + public string ProtocolKey { get; set; } + + [ProtoMember(19)] + public bool? IsValidFix { get; set; } + + [ProtoMember(20)] + public int? Satellites { get; set; } + + [ProtoMember(21)] + public decimal? Hdop { get; set; } + + [ProtoMember(22)] + public decimal? BatteryPercent { get; set; } + + [ProtoMember(23)] + public decimal? ExternalPowerVolts { get; set; } + + [ProtoMember(24)] + public int? SignalPercent { get; set; } + + [ProtoMember(25)] + public bool? Ignition { get; set; } + + [ProtoMember(26)] + public bool? IsMoving { get; set; } + + [ProtoMember(27)] + public string AlarmCode { get; set; } + + [ProtoMember(28)] + public int? TimestampSource { get; set; } public UnitLocationEvent() { diff --git a/Core/Resgrid.Model/Providers/IEventAggregator.cs b/Core/Resgrid.Model/Providers/IEventAggregator.cs index 0aaad5d4f..bbaebc8e1 100644 --- a/Core/Resgrid.Model/Providers/IEventAggregator.cs +++ b/Core/Resgrid.Model/Providers/IEventAggregator.cs @@ -26,6 +26,8 @@ public interface IEventSubscriptionManager /// Returns the current IEventSubscriptionManager to allow for easy fluent additions. Guid AddListener(Action listener); + Guid AddAsyncListener(Func listener); + /// /// Removes the listener object from the EventAggregator /// @@ -37,6 +39,7 @@ public interface IEventSubscriptionManager public interface IEventPublisher { void SendMessage(TMessage message); + Task SendMessageAsync(TMessage message); } public interface IEventAggregator : IEventPublisher, IEventSubscriptionManager diff --git a/Core/Resgrid.Model/Providers/IRabbitOutboundQueueProvider.cs b/Core/Resgrid.Model/Providers/IRabbitOutboundQueueProvider.cs index df79f60ef..088b9b754 100644 --- a/Core/Resgrid.Model/Providers/IRabbitOutboundQueueProvider.cs +++ b/Core/Resgrid.Model/Providers/IRabbitOutboundQueueProvider.cs @@ -1,5 +1,7 @@ using Resgrid.Model.Events; using Resgrid.Model.Queue; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Resgrid.Model.Providers @@ -14,6 +16,9 @@ public interface IRabbitOutboundQueueProvider Task EnqueueCqrsEvent(CqrsEvent cqrsEvent); Task EnqueueAuditEvent(AuditEvent auditEvent); Task EnqueueUnitLocationEvent(UnitLocationEvent unitLocationEvent); + Task EnqueueUnitLocationEvents( + IReadOnlyCollection unitLocationEvents, + CancellationToken cancellationToken = default); Task EnqueuePersonnelLocationEvent(PersonnelLocationEvent personnelLocationEvent); Task EnqueueWorkflowEvent(WorkflowQueueItem item); Task EnqueueChatbotMessage(ChatbotMessageQueueItem chatbotMessageQueue); diff --git a/Core/Resgrid.Model/Providers/IUnitLocationEventProvider.cs b/Core/Resgrid.Model/Providers/IUnitLocationEventProvider.cs index 833dee520..e85de421d 100644 --- a/Core/Resgrid.Model/Providers/IUnitLocationEventProvider.cs +++ b/Core/Resgrid.Model/Providers/IUnitLocationEventProvider.cs @@ -1,4 +1,6 @@ using Resgrid.Model.Events; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Resgrid.Model.Providers @@ -6,5 +8,8 @@ namespace Resgrid.Model.Providers public interface IUnitLocationEventProvider { Task EnqueueUnitLocationEventAsync(UnitLocationEvent unitLocationEvent); + Task EnqueueUnitLocationEventsAsync( + IReadOnlyCollection unitLocationEvents, + CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Repositories/IDocumentDbRepository.cs b/Core/Resgrid.Model/Repositories/IDocumentDbRepository.cs index 72ff7cb5d..fd96383db 100644 --- a/Core/Resgrid.Model/Repositories/IDocumentDbRepository.cs +++ b/Core/Resgrid.Model/Repositories/IDocumentDbRepository.cs @@ -8,7 +8,7 @@ namespace Resgrid.Model.Repositories public interface IDocumentDbRepository { /// - /// Updates the Postgres document database schema. + /// Updates the configured document database schema and indexes. /// /// If the operation was successful Task UpdateDocumentDatabaseAsync(); diff --git a/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs b/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs index 0e30eba6b..ba3dd0cc6 100644 --- a/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs +++ b/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs @@ -10,7 +10,7 @@ public interface IUnitLocationsDocRepository Task> GetLatestLocationsByDepartmentIdAsync(int departmentId); Task GetByIdAsync(string id); Task GetByOldIdAsync(string id); - Task InsertAsync(UnitsLocation location); - Task UpdateAsync(UnitsLocation location); + Task InsertAsync(UnitsLocation location); + Task UpdateAsync(UnitsLocation location); } } diff --git a/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs b/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs new file mode 100644 index 000000000..94d537f6c --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IUnitLocationsMongoRepository + { + Task EnsureIndexesAsync(); + Task InsertAsync(UnitsLocation location); + Task UpdateAsync(UnitsLocation location); + } +} diff --git a/Core/Resgrid.Model/Repositories/IUnitTrackingCredentialsRepository.cs b/Core/Resgrid.Model/Repositories/IUnitTrackingCredentialsRepository.cs new file mode 100644 index 000000000..fa8df6f09 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IUnitTrackingCredentialsRepository.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IUnitTrackingCredentialsRepository : IRepository + { + Task> GetAllByDeviceIdAsync(string unitTrackingDeviceId); + Task GetBySecretHashAsync(string secretHash); + } +} diff --git a/Core/Resgrid.Model/Repositories/IUnitTrackingDevicesRepository.cs b/Core/Resgrid.Model/Repositories/IUnitTrackingDevicesRepository.cs new file mode 100644 index 000000000..e673db92c --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IUnitTrackingDevicesRepository.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IUnitTrackingDevicesRepository : IRepository + { + Task> GetAllByUnitIdAsync(int departmentId, int unitId); + Task GetByProtocolIdentifierAsync(string protocolKey, string deviceIdentifier); + } +} diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs index d0054b6f4..c216563f0 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs @@ -320,5 +320,11 @@ Task SetUnitCallStatusOverridesByUnitTypeAsync(int department /// chatbot/SMS actions (overrides the per-user opt-in). /// Task GetForceChatbotSecurityPinAsync(int departmentId, bool bypassCache = false); + + Task GetHardwareTrackingStaleAfterSecondsAsync(int departmentId, bool bypassCache = false); + + Task GetHardwareTrackingMobileFallbackEnabledAsync(int departmentId, bool bypassCache = false); + + Task GetHardwareTrackingLocationRetentionDaysAsync(int departmentId, bool bypassCache = false); } } diff --git a/Core/Resgrid.Model/Services/IUnitLocationSourceResolver.cs b/Core/Resgrid.Model/Services/IUnitLocationSourceResolver.cs new file mode 100644 index 000000000..c31a33069 --- /dev/null +++ b/Core/Resgrid.Model/Services/IUnitLocationSourceResolver.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IUnitLocationSourceResolver + { + Task ResolveAsync( + int departmentId, + IReadOnlyCollection locations, + DateTime? utcNow = null); + } +} diff --git a/Core/Resgrid.Model/Services/IUnitTrackingAuthenticationService.cs b/Core/Resgrid.Model/Services/IUnitTrackingAuthenticationService.cs new file mode 100644 index 000000000..64f455583 --- /dev/null +++ b/Core/Resgrid.Model/Services/IUnitTrackingAuthenticationService.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IUnitTrackingAuthenticationService + { + UnitTrackingGeneratedCredential GenerateCredential(); + string ComputeSecretHash(string token); + bool VerifySecret(string token, string storedHash); + Task AuthenticateAsync( + string token, + DateTime? utcNow = null, + CancellationToken cancellationToken = default); + Task GetEnabledDeviceByEndpointIdAsync( + string deviceId, + CancellationToken cancellationToken = default); + Task GetEnabledDeviceByProtocolIdentifierAsync( + string protocolKey, + string deviceIdentifier, + CancellationToken cancellationToken = default); + Task> GetActiveCredentialsForDeviceAsync( + string deviceId, + DateTime? utcNow = null, + CancellationToken cancellationToken = default); + Task InvalidateCredentialAsync(string secretHash); + Task InvalidateDeviceAsync(UnitTrackingDevice device); + } +} diff --git a/Core/Resgrid.Model/Services/IUnitTrackingCatalogService.cs b/Core/Resgrid.Model/Services/IUnitTrackingCatalogService.cs new file mode 100644 index 000000000..f45c61abc --- /dev/null +++ b/Core/Resgrid.Model/Services/IUnitTrackingCatalogService.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Tracking; + +namespace Resgrid.Model.Services +{ + public interface IUnitTrackingCatalogService + { + Task> GetProfilesAsync( + CancellationToken cancellationToken = default); + + Task GetProfileAsync( + string profileKey, + CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IUnitTrackingEventIdService.cs b/Core/Resgrid.Model/Services/IUnitTrackingEventIdService.cs new file mode 100644 index 000000000..56fb56a39 --- /dev/null +++ b/Core/Resgrid.Model/Services/IUnitTrackingEventIdService.cs @@ -0,0 +1,7 @@ +namespace Resgrid.Model.Services +{ + public interface IUnitTrackingEventIdService + { + string CreateForHttps(string unitTrackingDeviceId, string callerEventId); + } +} diff --git a/Core/Resgrid.Model/Services/IUnitTrackingIdentifierService.cs b/Core/Resgrid.Model/Services/IUnitTrackingIdentifierService.cs new file mode 100644 index 000000000..7ed8c2efa --- /dev/null +++ b/Core/Resgrid.Model/Services/IUnitTrackingIdentifierService.cs @@ -0,0 +1,8 @@ +namespace Resgrid.Model.Services +{ + public interface IUnitTrackingIdentifierService + { + string Normalize(string identifier); + string Mask(string identifier); + } +} diff --git a/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs b/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs new file mode 100644 index 000000000..fcbd37ede --- /dev/null +++ b/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Tracking; + +namespace Resgrid.Model.Services +{ + public interface IUnitTrackingIngressService + { + Task AcceptAsync( + AuthenticatedTrackingSource source, + IReadOnlyCollection positions, + CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IUnitTrackingService.cs b/Core/Resgrid.Model/Services/IUnitTrackingService.cs new file mode 100644 index 000000000..cfe053451 --- /dev/null +++ b/Core/Resgrid.Model/Services/IUnitTrackingService.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IUnitTrackingService + { + Task GetDeviceByIdAsync(string deviceId, int departmentId); + Task> GetDevicesForDepartmentAsync(int departmentId); + Task> GetDevicesForUnitAsync(int departmentId, int unitId); + Task> GetCredentialsForDeviceAsync(string deviceId, int departmentId); + Task CreateDeviceAsync( + UnitTrackingDevice device, + int departmentId, + string userId, + CancellationToken cancellationToken = default); + Task UpdateDeviceAsync( + UnitTrackingDevice device, + int departmentId, + string userId, + CancellationToken cancellationToken = default); + Task DisableDeviceAsync( + string deviceId, + int departmentId, + string userId, + CancellationToken cancellationToken = default); + Task DeleteDeviceAsync( + string deviceId, + int departmentId, + string userId, + CancellationToken cancellationToken = default); + Task RebindDeviceAsync( + string deviceId, + int departmentId, + int newUnitId, + string userId, + CancellationToken cancellationToken = default); + Task CreateCredentialAsync( + string deviceId, + int departmentId, + UnitTrackingAuthMode authMode, + string userId, + string headerName = null, + string basicUsername = null, + CancellationToken cancellationToken = default); + Task RotateCredentialAsync( + string deviceId, + string credentialId, + int departmentId, + string userId, + TimeSpan? overlap = null, + CancellationToken cancellationToken = default); + Task RevokeCredentialAsync( + string deviceId, + string credentialId, + int departmentId, + string userId, + CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IUnitTrackingStatusService.cs b/Core/Resgrid.Model/Services/IUnitTrackingStatusService.cs new file mode 100644 index 000000000..0c5e9ada1 --- /dev/null +++ b/Core/Resgrid.Model/Services/IUnitTrackingStatusService.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IUnitTrackingStatusService + { + Task GetEffectiveStatusAsync( + UnitTrackingDevice device, + DateTime? utcNow = null, + CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IUnitsService.cs b/Core/Resgrid.Model/Services/IUnitsService.cs index effbefbcb..e98d3031f 100644 --- a/Core/Resgrid.Model/Services/IUnitsService.cs +++ b/Core/Resgrid.Model/Services/IUnitsService.cs @@ -304,8 +304,8 @@ Task DeleteStatesForUnitAsync(int unitId, /// /// The location. /// The cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// Task<UnitLocation>. - Task AddUnitLocationAsync(UnitsLocation location, int departmentId, + /// The idempotent document-store write result. + Task AddUnitLocationAsync(UnitsLocation location, int departmentId, CancellationToken cancellationToken = default(CancellationToken)); /// diff --git a/Core/Resgrid.Model/Tracking/AuthenticatedTrackingSource.cs b/Core/Resgrid.Model/Tracking/AuthenticatedTrackingSource.cs new file mode 100644 index 000000000..08662801e --- /dev/null +++ b/Core/Resgrid.Model/Tracking/AuthenticatedTrackingSource.cs @@ -0,0 +1,9 @@ +namespace Resgrid.Model.Tracking +{ + public sealed class AuthenticatedTrackingSource + { + public UnitTrackingDevice Device { get; set; } + public UnitTrackingCredential Credential { get; set; } + public string ReportedDeviceIdentifier { get; set; } + } +} diff --git a/Core/Resgrid.Model/Tracking/CanonicalTrackingPosition.cs b/Core/Resgrid.Model/Tracking/CanonicalTrackingPosition.cs new file mode 100644 index 000000000..5d5b06e90 --- /dev/null +++ b/Core/Resgrid.Model/Tracking/CanonicalTrackingPosition.cs @@ -0,0 +1,27 @@ +using System; + +namespace Resgrid.Model.Tracking +{ + public sealed class CanonicalTrackingPosition + { + public string EventId { get; set; } + public DateTime TimestampUtc { get; set; } + public DateTime ReceivedOnUtc { get; set; } + public decimal Latitude { get; set; } + public decimal Longitude { get; set; } + public decimal? AccuracyMeters { get; set; } + public decimal? AltitudeMeters { get; set; } + public decimal? SpeedMetersPerSecond { get; set; } + public decimal? HeadingDegrees { get; set; } + public int? Satellites { get; set; } + public decimal? Hdop { get; set; } + public decimal? BatteryPercent { get; set; } + public decimal? ExternalPowerVolts { get; set; } + public int? SignalPercent { get; set; } + public bool? Ignition { get; set; } + public bool? IsMoving { get; set; } + public string AlarmCode { get; set; } + public TrackingTimestampSource TimestampSource { get; set; } + public bool IsValidFix { get; set; } + } +} diff --git a/Core/Resgrid.Model/Tracking/TrackingIngressResult.cs b/Core/Resgrid.Model/Tracking/TrackingIngressResult.cs new file mode 100644 index 000000000..a5a249593 --- /dev/null +++ b/Core/Resgrid.Model/Tracking/TrackingIngressResult.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Tracking +{ + public enum TrackingIngressStatus + { + Accepted = 0, + Invalid = 1, + Unavailable = 2 + } + + public sealed class TrackingIngressResult + { + public TrackingIngressStatus Status { get; set; } + public int Accepted { get; set; } + public bool DuplicatesPossible { get; set; } + public DateTime ReceivedOn { get; set; } + public IReadOnlyCollection Errors { get; set; } = Array.Empty(); + } +} diff --git a/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs b/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs new file mode 100644 index 000000000..f53abe061 --- /dev/null +++ b/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Tracking +{ + public sealed class UnitTrackingCatalogProfile + { + public string Key { get; set; } + public string ManufacturerKey { get; set; } + public string ManufacturerName { get; set; } + public string Model { get; set; } + public UnitTrackingTransportType TransportType { get; set; } + public string ProtocolKey { get; set; } + public string PayloadAdapterKey { get; set; } + public UnitTrackingCertificationStatus CertificationStatus { get; set; } + public bool IdentifierRequired { get; set; } + public bool IsSelectable { get; set; } + public IReadOnlyCollection SupportedAuthModes { get; set; } = + Array.Empty(); + public string SetupSummary { get; set; } + public string RetryExpectation { get; set; } + } +} diff --git a/Core/Resgrid.Model/UnitLocationWriteResult.cs b/Core/Resgrid.Model/UnitLocationWriteResult.cs new file mode 100644 index 000000000..21ad4b1ea --- /dev/null +++ b/Core/Resgrid.Model/UnitLocationWriteResult.cs @@ -0,0 +1,32 @@ +namespace Resgrid.Model +{ + public enum UnitLocationWriteStatus + { + Inserted = 0, + Duplicate = 1 + } + + public class UnitLocationWriteResult + { + public UnitLocationWriteStatus Status { get; set; } + public UnitsLocation Location { get; set; } + + public static UnitLocationWriteResult Inserted(UnitsLocation location) + { + return new UnitLocationWriteResult + { + Status = UnitLocationWriteStatus.Inserted, + Location = location + }; + } + + public static UnitLocationWriteResult Duplicate(UnitsLocation location) + { + return new UnitLocationWriteResult + { + Status = UnitLocationWriteStatus.Duplicate, + Location = location + }; + } + } +} diff --git a/Core/Resgrid.Model/UnitTrackingCredential.cs b/Core/Resgrid.Model/UnitTrackingCredential.cs new file mode 100644 index 000000000..1f5eb2fc9 --- /dev/null +++ b/Core/Resgrid.Model/UnitTrackingCredential.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + public class UnitTrackingCredential : IEntity + { + [Key] + [Required] + [MaxLength(128)] + public string UnitTrackingCredentialId { get; set; } + + [Required] + [MaxLength(128)] + public string UnitTrackingDeviceId { get; set; } + + [ForeignKey(nameof(UnitTrackingDeviceId))] + public virtual UnitTrackingDevice UnitTrackingDevice { get; set; } + + [Required] + public int AuthMode { get; set; } + + [MaxLength(128)] + public string HeaderName { get; set; } + + [MaxLength(128)] + public string BasicUsername { get; set; } + + [Required] + [MaxLength(20)] + public string KeyPrefix { get; set; } + + [Required] + [MaxLength(64)] + [JsonIgnore] + public string SecretHash { get; set; } + + public DateTime ValidFrom { get; set; } + + public DateTime? ExpiresOn { get; set; } + + public DateTime? RevokedOn { get; set; } + + public DateTime? LastUsedOn { get; set; } + + [Required] + public string CreatedByUserId { get; set; } + + public DateTime CreatedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get => UnitTrackingCredentialId; + set => UnitTrackingCredentialId = (string)value; + } + + [NotMapped] + public string TableName => "UnitTrackingCredentials"; + + [NotMapped] + public string IdName => "UnitTrackingCredentialId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => + new[] { "IdValue", "IdType", "TableName", "IdName", "UnitTrackingDevice" }; + } +} diff --git a/Core/Resgrid.Model/UnitTrackingDevice.cs b/Core/Resgrid.Model/UnitTrackingDevice.cs new file mode 100644 index 000000000..58a62ba7b --- /dev/null +++ b/Core/Resgrid.Model/UnitTrackingDevice.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + public class UnitTrackingDevice : IEntity + { + [Key] + [Required] + [MaxLength(128)] + public string UnitTrackingDeviceId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + [ForeignKey(nameof(DepartmentId))] + public virtual Department Department { get; set; } + + [Required] + public int UnitId { get; set; } + + [ForeignKey(nameof(UnitId))] + public virtual Unit Unit { get; set; } + + [MaxLength(200)] + public string DisplayName { get; set; } + + [MaxLength(64)] + public string ManufacturerKey { get; set; } + + [MaxLength(64)] + public string ModelKey { get; set; } + + [Required] + public int TransportType { get; set; } + + [MaxLength(64)] + public string ProtocolKey { get; set; } + + [MaxLength(64)] + public string PayloadAdapterKey { get; set; } + + [MaxLength(128)] + public string DeviceIdentifier { get; set; } + + [MaxLength(128)] + public string SecondaryIdentifier { get; set; } + + public bool IsEnabled { get; set; } = true; + + public bool IsDeleted { get; set; } + + public int SourcePriority { get; set; } = 100; + + public string AllowedSourceCidrs { get; set; } + + public DateTime? LastSeenOn { get; set; } + + public DateTime? LastPositionOn { get; set; } + + public DateTime? LastReceivedOn { get; set; } + + public int LastStatus { get; set; } = (int)UnitTrackingDeviceStatus.NeverSeen; + + [MaxLength(64)] + public string LastErrorCode { get; set; } + + [MaxLength(128)] + public string FirmwareVersion { get; set; } + + [Required] + public string CreatedByUserId { get; set; } + + public DateTime CreatedOn { get; set; } + + public string UpdatedByUserId { get; set; } + + public DateTime? UpdatedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get => UnitTrackingDeviceId; + set => UnitTrackingDeviceId = (string)value; + } + + [NotMapped] + public string TableName => "UnitTrackingDevices"; + + [NotMapped] + public string IdName => "UnitTrackingDeviceId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => + new[] { "IdValue", "IdType", "TableName", "IdName", "Department", "Unit" }; + } +} diff --git a/Core/Resgrid.Model/UnitTrackingEnums.cs b/Core/Resgrid.Model/UnitTrackingEnums.cs new file mode 100644 index 000000000..497de104c --- /dev/null +++ b/Core/Resgrid.Model/UnitTrackingEnums.cs @@ -0,0 +1,53 @@ +namespace Resgrid.Model +{ + public enum UnitLocationSourceType + { + UnknownLegacy = 0, + UnitApp = 1, + HardwareTracker = 2 + } + + public enum UnitTrackingTransportType + { + Unknown = 0, + NativeHttps = 1, + ManagedHttpsJson = 2, + NativeTcpUdp = 3, + ProtocolGateway = 4 + } + + public enum UnitTrackingAuthMode + { + Unknown = 0, + Bearer = 1, + Basic = 2, + CustomHeader = 3, + CapabilityPath = 4 + } + + public enum UnitTrackingDeviceStatus + { + NeverSeen = 0, + Online = 1, + Stale = 2, + Error = 3, + Disabled = 4 + } + + public enum TrackingTimestampSource + { + Unknown = 0, + Device = 1, + Server = 2 + } + + public enum UnitTrackingCertificationStatus + { + Unknown = 0, + Candidate = 1, + FixtureVerified = 2, + HardwareVerified = 3, + Certified = 4, + Deprecated = 5 + } +} diff --git a/Core/Resgrid.Model/UnitTrackingResults.cs b/Core/Resgrid.Model/UnitTrackingResults.cs new file mode 100644 index 000000000..22c065ce1 --- /dev/null +++ b/Core/Resgrid.Model/UnitTrackingResults.cs @@ -0,0 +1,31 @@ +namespace Resgrid.Model +{ + public sealed class UnitTrackingGeneratedCredential + { + public string Token { get; set; } + public string KeyPrefix { get; set; } + public string SecretHash { get; set; } + } + + public sealed class UnitTrackingCredentialProvisionResult + { + public UnitTrackingCredential Credential { get; set; } + public string Token { get; set; } + public string EndpointUrl { get; set; } + public string HeaderName { get; set; } + public string HeaderValue { get; set; } + public string BasicUsername { get; set; } + } + + public sealed class UnitTrackingAuthenticationResult + { + public UnitTrackingDevice Device { get; set; } + public UnitTrackingCredential Credential { get; set; } + } + + public sealed class ResolvedUnitLocation + { + public UnitsLocation Location { get; set; } + public bool IsStale { get; set; } + } +} diff --git a/Core/Resgrid.Model/UnitsLocation.cs b/Core/Resgrid.Model/UnitsLocation.cs index b4b4cf791..226b6b273 100644 --- a/Core/Resgrid.Model/UnitsLocation.cs +++ b/Core/Resgrid.Model/UnitsLocation.cs @@ -9,6 +9,10 @@ namespace Resgrid.Model [BsonCollection("unitLocations")] public class UnitsLocation : NoSqlDocument { + [BsonElement("eventId")] + [BsonIgnoreIfNull] + public string EventId { get; set; } + [BsonElement("departmentId")] public int DepartmentId { get; set; } @@ -19,6 +23,27 @@ public class UnitsLocation : NoSqlDocument [BsonElement("timestamp")] public DateTime Timestamp { get; set; } + [BsonElement("receivedOn")] + public DateTime? ReceivedOn { get; set; } + + [BsonElement("sourceType")] + public int SourceType { get; set; } + + [BsonElement("sourceId")] + public string SourceId { get; set; } + + [BsonElement("sourcePriority")] + public int SourcePriority { get; set; } + + [BsonElement("transportType")] + public int? TransportType { get; set; } + + [BsonElement("protocolKey")] + public string ProtocolKey { get; set; } + + [BsonElement("isValidFix")] + public bool? IsValidFix { get; set; } + [BsonElement("latitude")] public decimal Latitude { get; set; } @@ -40,6 +65,33 @@ public class UnitsLocation : NoSqlDocument [BsonElement("heading")] public decimal? Heading { get; set; } + [BsonElement("satellites")] + public int? Satellites { get; set; } + + [BsonElement("hdop")] + public decimal? Hdop { get; set; } + + [BsonElement("batteryPercent")] + public decimal? BatteryPercent { get; set; } + + [BsonElement("externalPowerVolts")] + public decimal? ExternalPowerVolts { get; set; } + + [BsonElement("signalPercent")] + public int? SignalPercent { get; set; } + + [BsonElement("ignition")] + public bool? Ignition { get; set; } + + [BsonElement("isMoving")] + public bool? IsMoving { get; set; } + + [BsonElement("alarmCode")] + public string AlarmCode { get; set; } + + [BsonElement("timestampSource")] + public int? TimestampSource { get; set; } + [BsonIgnore()] public string PgId { get; set; } diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs index 545fcee97..c5d5d1a5d 100644 --- a/Core/Resgrid.Services/AuditService.cs +++ b/Core/Resgrid.Services/AuditService.cs @@ -178,6 +178,20 @@ public string GetAuditLogTypeString(AuditLogTypes logType) return "Calendar Check-In Deleted"; case AuditLogTypes.CalendarAdminCheckInPerformed: return "Admin Calendar Check-In"; + case AuditLogTypes.UnitTrackingDeviceCreated: + return "Unit Tracking Device Created"; + case AuditLogTypes.UnitTrackingDeviceUpdated: + return "Unit Tracking Device Updated"; + case AuditLogTypes.UnitTrackingDeviceDisabled: + return "Unit Tracking Device Disabled"; + case AuditLogTypes.UnitTrackingDeviceDeleted: + return "Unit Tracking Device Deleted"; + case AuditLogTypes.UnitTrackingCredentialCreated: + return "Unit Tracking Credential Created"; + case AuditLogTypes.UnitTrackingCredentialRotated: + return "Unit Tracking Credential Rotated"; + case AuditLogTypes.UnitTrackingCredentialRevoked: + return "Unit Tracking Credential Revoked"; } return $"Unknown ({logType})"; diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index df62825f7..83aa5601c 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -24,6 +24,9 @@ public class DepartmentSettingsService : IDepartmentSettingsService private static string PersonnelOnUnitSetUnitStatusCacheKey = "DSetPersonnelOnUnitSetUnitStatus_{0}"; private static string ModernNotificationsCacheKey = "DSetModernNotifications_{0}"; private static string ForceChatbotSecurityPinCacheKey = "DSetForceChatbotSecurityPin_{0}"; + private static string HardwareTrackingStaleAfterSecondsCacheKey = "DSetHardwareTrackingStale_{0}"; + private static string HardwareTrackingMobileFallbackCacheKey = "DSetHardwareTrackingFallback_{0}"; + private static string HardwareTrackingRetentionDaysCacheKey = "DSetHardwareTrackingRetention_{0}"; private static TimeSpan LongCacheLength = TimeSpan.FromDays(14); private static TimeSpan ThatsNotLongThisIsLongCacheLength = TimeSpan.FromDays(365); private static TimeSpan TwoYearCacheLength = TimeSpan.FromDays(730); @@ -45,14 +48,10 @@ public DepartmentSettingsService(IDepartmentSettingsRepository departmentSetting public async Task SaveOrUpdateSettingAsync(int departmentId, string setting, DepartmentSettingTypes type, CancellationToken cancellationToken = default(CancellationToken)) { var savedSetting = await GetSettingByDepartmentIdType(departmentId, type); + await InvalidateSettingCacheAsync(departmentId, type); if (savedSetting == null) { - // First-time write still needs to clear any cache populated by the fallback - // "new DepartmentModuleSettings()" in GetDepartmentModuleSettingsAsync (cached 365 days). - if (type == DepartmentSettingTypes.ModuleSettings) - await _cacheProvider.RemoveAsync(string.Format(ModuleSettingsCacheKey, departmentId)); - DepartmentSetting newSetting = new DepartmentSetting(); newSetting.DepartmentId = departmentId; newSetting.Setting = setting; @@ -62,35 +61,6 @@ public DepartmentSettingsService(IDepartmentSettingsRepository departmentSetting } else { - // Clear out Cache - switch (type) - { - case DepartmentSettingTypes.BigBoardMapCenterGpsCoordinates: - await _cacheProvider.RemoveAsync(string.Format(BigBoardCenterGps, departmentId)); - break; - case DepartmentSettingTypes.DisabledAutoAvailable: - await _cacheProvider.RemoveAsync(string.Format(DisableAutoAvailableCacheKey, departmentId)); - break; - case DepartmentSettingTypes.StaffingSuppressStaffingLevels: - await _cacheProvider.RemoveAsync(string.Format(StaffingSupressInfo, departmentId)); - break; - case DepartmentSettingTypes.ModuleSettings: - await _cacheProvider.RemoveAsync(string.Format(ModuleSettingsCacheKey, departmentId)); - break; - case DepartmentSettingTypes.TtsLanguage: - await _cacheProvider.RemoveAsync(string.Format(TtsLanguageCacheKey, departmentId)); - break; - case DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus: - await _cacheProvider.RemoveAsync(string.Format(PersonnelOnUnitSetUnitStatusCacheKey, departmentId)); - break; - case DepartmentSettingTypes.EnableModernNotifications: - await _cacheProvider.RemoveAsync(string.Format(ModernNotificationsCacheKey, departmentId)); - break; - case DepartmentSettingTypes.ForceChatbotSecurityPin: - await _cacheProvider.RemoveAsync(string.Format(ForceChatbotSecurityPinCacheKey, departmentId)); - break; - } - savedSetting.Setting = setting; return await _departmentSettingsRepository.SaveOrUpdateAsync(savedSetting, cancellationToken); } @@ -103,7 +73,13 @@ public DepartmentSettingsService(IDepartmentSettingsRepository departmentSetting var savedSetting = await GetSettingByDepartmentIdType(departmentId, type); if (savedSetting != null) - return await _departmentSettingsRepository.DeleteAsync(savedSetting, cancellationToken); + { + var deleted = await _departmentSettingsRepository.DeleteAsync(savedSetting, cancellationToken); + if (deleted) + await InvalidateSettingCacheAsync(departmentId, type); + + return deleted; + } return false; } @@ -978,6 +954,117 @@ async Task getSetting() return bool.Parse(await getSetting()); } + public async Task GetHardwareTrackingStaleAfterSecondsAsync(int departmentId, bool bypassCache = false) + { + async Task getSetting() + { + var setting = await GetSettingByDepartmentIdType( + departmentId, + DepartmentSettingTypes.HardwareTrackingStaleAfterSeconds); + return setting?.Setting ?? "180"; + } + + var value = Config.SystemBehaviorConfig.CacheEnabled && !bypassCache + ? await _cacheProvider.RetrieveAsync( + string.Format(HardwareTrackingStaleAfterSecondsCacheKey, departmentId), + getSetting, + LongCacheLength) + : await getSetting(); + + return int.TryParse(value, out var seconds) ? Math.Max(1, seconds) : 180; + } + + public async Task GetHardwareTrackingMobileFallbackEnabledAsync(int departmentId, bool bypassCache = false) + { + async Task getSetting() + { + var setting = await GetSettingByDepartmentIdType( + departmentId, + DepartmentSettingTypes.HardwareTrackingMobileFallbackEnabled); + return setting?.Setting ?? "true"; + } + + var value = Config.SystemBehaviorConfig.CacheEnabled && !bypassCache + ? await _cacheProvider.RetrieveAsync( + string.Format(HardwareTrackingMobileFallbackCacheKey, departmentId), + getSetting, + LongCacheLength) + : await getSetting(); + + return !bool.TryParse(value, out var enabled) || enabled; + } + + public async Task GetHardwareTrackingLocationRetentionDaysAsync(int departmentId, bool bypassCache = false) + { + async Task getSetting() + { + var setting = await GetSettingByDepartmentIdType( + departmentId, + DepartmentSettingTypes.HardwareTrackingLocationRetentionDays); + return setting?.Setting ?? UnitTrackingConfig.DefaultLocationRetentionDays.ToString(); + } + + var value = Config.SystemBehaviorConfig.CacheEnabled && !bypassCache + ? await _cacheProvider.RetrieveAsync( + string.Format(HardwareTrackingRetentionDaysCacheKey, departmentId), + getSetting, + LongCacheLength) + : await getSetting(); + + var retentionDays = int.TryParse(value, out var parsed) + ? parsed + : UnitTrackingConfig.DefaultLocationRetentionDays; + + return Math.Min( + UnitTrackingConfig.MaximumLocationRetentionDays, + Math.Max(UnitTrackingConfig.MinimumLocationRetentionDays, retentionDays)); + } + + private async Task InvalidateSettingCacheAsync(int departmentId, DepartmentSettingTypes type) + { + string cacheKey = null; + + switch (type) + { + case DepartmentSettingTypes.BigBoardMapCenterGpsCoordinates: + cacheKey = string.Format(BigBoardCenterGps, departmentId); + break; + case DepartmentSettingTypes.DisabledAutoAvailable: + cacheKey = string.Format(DisableAutoAvailableCacheKey, departmentId); + break; + case DepartmentSettingTypes.StaffingSuppressStaffingLevels: + cacheKey = string.Format(StaffingSupressInfo, departmentId); + break; + case DepartmentSettingTypes.ModuleSettings: + cacheKey = string.Format(ModuleSettingsCacheKey, departmentId); + break; + case DepartmentSettingTypes.TtsLanguage: + cacheKey = string.Format(TtsLanguageCacheKey, departmentId); + break; + case DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus: + cacheKey = string.Format(PersonnelOnUnitSetUnitStatusCacheKey, departmentId); + break; + case DepartmentSettingTypes.EnableModernNotifications: + cacheKey = string.Format(ModernNotificationsCacheKey, departmentId); + break; + case DepartmentSettingTypes.ForceChatbotSecurityPin: + cacheKey = string.Format(ForceChatbotSecurityPinCacheKey, departmentId); + break; + case DepartmentSettingTypes.HardwareTrackingStaleAfterSeconds: + cacheKey = string.Format(HardwareTrackingStaleAfterSecondsCacheKey, departmentId); + break; + case DepartmentSettingTypes.HardwareTrackingMobileFallbackEnabled: + cacheKey = string.Format(HardwareTrackingMobileFallbackCacheKey, departmentId); + break; + case DepartmentSettingTypes.HardwareTrackingLocationRetentionDays: + cacheKey = string.Format(HardwareTrackingRetentionDaysCacheKey, departmentId); + break; + } + + if (!string.IsNullOrWhiteSpace(cacheKey)) + await _cacheProvider.RemoveAsync(cacheKey); + } + private static string GetDefaultTtsLanguage() { if (EspeakVoiceCatalog.TryNormalizeIdentifier(TtsConfig.DefaultVoice, out var normalizedVoice)) diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 1f20f7da2..a6e620892 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -56,6 +56,14 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/UnitLocationSourceResolver.cs b/Core/Resgrid.Services/UnitLocationSourceResolver.cs new file mode 100644 index 000000000..0fce5c1db --- /dev/null +++ b/Core/Resgrid.Services/UnitLocationSourceResolver.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class UnitLocationSourceResolver : IUnitLocationSourceResolver + { + private readonly IDepartmentSettingsService _departmentSettingsService; + + public UnitLocationSourceResolver(IDepartmentSettingsService departmentSettingsService) + { + _departmentSettingsService = departmentSettingsService; + } + + public async Task ResolveAsync( + int departmentId, + IReadOnlyCollection locations, + DateTime? utcNow = null) + { + if (departmentId <= 0) + throw new ArgumentOutOfRangeException(nameof(departmentId)); + + if (locations == null || locations.Count == 0) + return null; + + var latestPerSource = locations + .Where(location => + location != null && + location.DepartmentId == departmentId && + location.IsValidFix != false) + .GroupBy(location => new + { + location.SourceType, + SourceId = location.SourceId ?? string.Empty + }) + .Select(group => group + .OrderByDescending(location => location.Timestamp) + .ThenByDescending(location => location.ReceivedOn ?? location.Timestamp) + .First()) + .ToList(); + + if (latestPerSource.Count == 0) + return null; + + var staleAfterSeconds = + await _departmentSettingsService.GetHardwareTrackingStaleAfterSecondsAsync(departmentId); + var mobileFallbackEnabled = + await _departmentSettingsService.GetHardwareTrackingMobileFallbackEnabledAsync(departmentId); + var now = utcNow ?? DateTime.UtcNow; + var freshThreshold = now.AddSeconds(-Math.Max(1, staleAfterSeconds)); + var fresh = latestPerSource + .Where(location => (location.ReceivedOn ?? location.Timestamp) >= freshThreshold) + .ToList(); + + var hasHardwareHistory = latestPerSource.Any(location => + location.SourceType == (int)UnitLocationSourceType.HardwareTracker); + var hasFreshHardware = fresh.Any(location => + location.SourceType == (int)UnitLocationSourceType.HardwareTracker); + + if (!mobileFallbackEnabled && hasHardwareHistory && !hasFreshHardware) + return null; + + var selected = fresh + .OrderByDescending(location => location.SourcePriority) + .ThenByDescending(location => location.Timestamp) + .ThenByDescending(location => location.ReceivedOn ?? location.Timestamp) + .FirstOrDefault(); + + if (selected != null) + { + return new ResolvedUnitLocation + { + Location = selected, + IsStale = false + }; + } + + if (!mobileFallbackEnabled) + return null; + + selected = latestPerSource + .OrderByDescending(location => location.Timestamp) + .ThenByDescending(location => location.ReceivedOn ?? location.Timestamp) + .First(); + + return new ResolvedUnitLocation + { + Location = selected, + IsStale = true + }; + } + } +} diff --git a/Core/Resgrid.Services/UnitTrackingAuthenticationService.cs b/Core/Resgrid.Services/UnitTrackingAuthenticationService.cs new file mode 100644 index 000000000..38a2d7f60 --- /dev/null +++ b/Core/Resgrid.Services/UnitTrackingAuthenticationService.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class UnitTrackingAuthenticationService : IUnitTrackingAuthenticationService + { + private readonly IUnitTrackingCredentialsRepository _credentialsRepository; + private readonly IUnitTrackingDevicesRepository _devicesRepository; + private readonly IUnitTrackingIdentifierService _identifierService; + private readonly ICacheProvider _cacheProvider; + + public UnitTrackingAuthenticationService( + IUnitTrackingCredentialsRepository credentialsRepository, + IUnitTrackingDevicesRepository devicesRepository, + IUnitTrackingIdentifierService identifierService, + ICacheProvider cacheProvider) + { + _credentialsRepository = credentialsRepository; + _devicesRepository = devicesRepository; + _identifierService = identifierService; + _cacheProvider = cacheProvider; + } + + public UnitTrackingGeneratedCredential GenerateCredential() + { + EnsurePepperConfigured(); + + var randomBytes = RandomNumberGenerator.GetBytes(32); + var encodedSecret = Convert.ToBase64String(randomBytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + var keyPrefix = encodedSecret.Substring(0, 8); + var token = $"rgtrk_{keyPrefix}_{encodedSecret}"; + + return new UnitTrackingGeneratedCredential + { + Token = token, + KeyPrefix = keyPrefix, + SecretHash = ComputeSecretHash(token) + }; + } + + public string ComputeSecretHash(string token) + { + if (string.IsNullOrWhiteSpace(token)) + throw new ArgumentNullException(nameof(token)); + + EnsurePepperConfigured(); + + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(UnitTrackingConfig.CredentialPepper)); + return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(token))).ToLowerInvariant(); + } + + public bool VerifySecret(string token, string storedHash) + { + if (string.IsNullOrWhiteSpace(token) || + string.IsNullOrWhiteSpace(storedHash) || + storedHash.Length != 64) + return false; + + try + { + var computed = Convert.FromHexString(ComputeSecretHash(token)); + var stored = Convert.FromHexString(storedHash); + return CryptographicOperations.FixedTimeEquals(computed, stored); + } + catch (FormatException) + { + return false; + } + } + + public async Task AuthenticateAsync( + string token, + DateTime? utcNow = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(token)) + return null; + + cancellationToken.ThrowIfCancellationRequested(); + var secretHash = ComputeSecretHash(token); + + async Task load() + { + var credential = await _credentialsRepository.GetBySecretHashAsync(secretHash); + if (credential == null || !VerifySecret(token, credential.SecretHash)) + return null; + + var device = await _devicesRepository.GetByIdAsync(credential.UnitTrackingDeviceId); + if (device == null) + return null; + + return new UnitTrackingAuthenticationResult + { + Device = device, + Credential = credential + }; + } + + var result = SystemBehaviorConfig.CacheEnabled + ? await _cacheProvider.RetrieveAsync( + UnitTrackingCacheKeys.Credential(secretHash), + load, + TimeSpan.FromSeconds(Math.Max(1, Math.Min(60, UnitTrackingConfig.CredentialCacheSeconds)))) + : await load(); + + return IsActive(result, utcNow ?? DateTime.UtcNow) ? result : null; + } + + public async Task GetEnabledDeviceByEndpointIdAsync( + string deviceId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(deviceId)) + return null; + + cancellationToken.ThrowIfCancellationRequested(); + + async Task load() + { + var device = await _devicesRepository.GetByIdAsync(deviceId); + return IsEnabled(device) ? device : null; + } + + return SystemBehaviorConfig.CacheEnabled + ? await _cacheProvider.RetrieveAsync( + UnitTrackingCacheKeys.Endpoint(deviceId), + load, + TimeSpan.FromSeconds(Math.Max(1, Math.Min(300, UnitTrackingConfig.DeviceMappingCacheSeconds)))) + : await load(); + } + + public async Task GetEnabledDeviceByProtocolIdentifierAsync( + string protocolKey, + string deviceIdentifier, + CancellationToken cancellationToken = default) + { + var normalizedProtocol = NormalizeKey(protocolKey); + var normalizedIdentifier = _identifierService.Normalize(deviceIdentifier); + if (normalizedProtocol == null || normalizedIdentifier == null) + return null; + + cancellationToken.ThrowIfCancellationRequested(); + + async Task load() + { + var device = await _devicesRepository.GetByProtocolIdentifierAsync( + normalizedProtocol, + normalizedIdentifier); + return IsEnabled(device) ? device : null; + } + + return SystemBehaviorConfig.CacheEnabled + ? await _cacheProvider.RetrieveAsync( + UnitTrackingCacheKeys.ProtocolIdentifier(normalizedProtocol, normalizedIdentifier), + load, + TimeSpan.FromSeconds(Math.Max(1, Math.Min(300, UnitTrackingConfig.DeviceMappingCacheSeconds)))) + : await load(); + } + + public async Task> GetActiveCredentialsForDeviceAsync( + string deviceId, + DateTime? utcNow = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(deviceId)) + return Array.Empty(); + + cancellationToken.ThrowIfCancellationRequested(); + var device = await GetEnabledDeviceByEndpointIdAsync(deviceId, cancellationToken); + if (device == null) + return Array.Empty(); + + async Task> load() + { + var credentials = await _credentialsRepository.GetAllByDeviceIdAsync(deviceId); + return credentials? + .Select(SanitizeCredential) + .ToList() ?? new List(); + } + + var cached = SystemBehaviorConfig.CacheEnabled + ? await _cacheProvider.RetrieveAsync( + UnitTrackingCacheKeys.DeviceCredentials(deviceId), + load, + TimeSpan.FromSeconds(Math.Max(1, Math.Min(60, UnitTrackingConfig.CredentialCacheSeconds)))) + : await load(); + var now = utcNow ?? DateTime.UtcNow; + + return cached + .Where(credential => IsActive(credential, now)) + .ToList(); + } + + public async Task InvalidateCredentialAsync(string secretHash) + { + if (!string.IsNullOrWhiteSpace(secretHash)) + await _cacheProvider.RemoveAsync(UnitTrackingCacheKeys.Credential(secretHash)); + } + + public async Task InvalidateDeviceAsync(UnitTrackingDevice device) + { + if (device == null || string.IsNullOrWhiteSpace(device.UnitTrackingDeviceId)) + return; + + await _cacheProvider.RemoveAsync(UnitTrackingCacheKeys.Endpoint(device.UnitTrackingDeviceId)); + await _cacheProvider.RemoveAsync(UnitTrackingCacheKeys.DeviceCredentials(device.UnitTrackingDeviceId)); + + var protocolKey = NormalizeKey(device.ProtocolKey); + var identifier = _identifierService.Normalize(device.DeviceIdentifier); + if (protocolKey != null && identifier != null) + { + await _cacheProvider.RemoveAsync( + UnitTrackingCacheKeys.ProtocolIdentifier(protocolKey, identifier)); + } + } + + private static bool IsActive(UnitTrackingAuthenticationResult result, DateTime utcNow) + { + if (result?.Credential == null || !IsEnabled(result.Device)) + return false; + + return result.Credential.ValidFrom <= utcNow && + !result.Credential.RevokedOn.HasValue && + (!result.Credential.ExpiresOn.HasValue || result.Credential.ExpiresOn > utcNow); + } + + private static bool IsActive(UnitTrackingCredential credential, DateTime utcNow) + { + return credential != null && + credential.ValidFrom <= utcNow && + !credential.RevokedOn.HasValue && + (!credential.ExpiresOn.HasValue || credential.ExpiresOn > utcNow); + } + + private static bool IsEnabled(UnitTrackingDevice device) => + device != null && device.IsEnabled && !device.IsDeleted; + + private static string NormalizeKey(string value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + + private static UnitTrackingCredential SanitizeCredential(UnitTrackingCredential credential) + { + return new UnitTrackingCredential + { + UnitTrackingCredentialId = credential.UnitTrackingCredentialId, + UnitTrackingDeviceId = credential.UnitTrackingDeviceId, + AuthMode = credential.AuthMode, + HeaderName = credential.HeaderName, + BasicUsername = credential.BasicUsername, + KeyPrefix = credential.KeyPrefix, + ValidFrom = credential.ValidFrom, + ExpiresOn = credential.ExpiresOn, + RevokedOn = credential.RevokedOn, + LastUsedOn = credential.LastUsedOn, + CreatedByUserId = credential.CreatedByUserId, + CreatedOn = credential.CreatedOn + }; + } + + private static void EnsurePepperConfigured() + { + if (string.IsNullOrWhiteSpace(UnitTrackingConfig.CredentialPepper)) + { + throw new InvalidOperationException( + "UnitTrackingConfig.CredentialPepper must be configured before using tracking credentials."); + } + } + } +} diff --git a/Core/Resgrid.Services/UnitTrackingCacheKeys.cs b/Core/Resgrid.Services/UnitTrackingCacheKeys.cs new file mode 100644 index 000000000..58623ea43 --- /dev/null +++ b/Core/Resgrid.Services/UnitTrackingCacheKeys.cs @@ -0,0 +1,30 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Resgrid.Services +{ + internal static class UnitTrackingCacheKeys + { + public static string Endpoint(string deviceId) => + $"UnitTracking:Endpoint:{Digest(deviceId)}"; + + public static string Credential(string secretHash) => + $"UnitTracking:Credential:{secretHash?.ToLowerInvariant()}"; + + public static string DeviceCredentials(string deviceId) => + $"UnitTracking:DeviceCredentials:{Digest(deviceId)}"; + + public static string ProtocolIdentifier(string protocolKey, string deviceIdentifier) => + $"UnitTracking:Protocol:{Digest($"{protocolKey}|{deviceIdentifier}")}"; + + private static string Digest(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentNullException(nameof(value)); + + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))) + .ToLowerInvariant(); + } + } +} diff --git a/Core/Resgrid.Services/UnitTrackingCatalogService.cs b/Core/Resgrid.Services/UnitTrackingCatalogService.cs new file mode 100644 index 000000000..09e7ebbc3 --- /dev/null +++ b/Core/Resgrid.Services/UnitTrackingCatalogService.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Model.Tracking; + +namespace Resgrid.Services +{ + public class UnitTrackingCatalogService : IUnitTrackingCatalogService + { + private static readonly IReadOnlyCollection Profiles = + new[] + { + new UnitTrackingCatalogProfile + { + Key = "generic-https", + ManufacturerKey = "generic", + ManufacturerName = "Generic", + Model = "Resgrid JSON", + TransportType = UnitTrackingTransportType.NativeHttps, + ProtocolKey = "resgrid-json", + PayloadAdapterKey = "resgrid-json-v1", + CertificationStatus = UnitTrackingCertificationStatus.Certified, + IdentifierRequired = false, + IsSelectable = true, + SupportedAuthModes = new[] + { + UnitTrackingAuthMode.Bearer, + UnitTrackingAuthMode.Basic, + UnitTrackingAuthMode.CustomHeader, + UnitTrackingAuthMode.CapabilityPath + }, + SetupSummary = + "POST the documented Resgrid JSON position payload to the generated HTTPS endpoint.", + RetryExpectation = + "Retry on 429 and 503. Treat 200, 201, 202, and 204 as successful delivery." + }, + new UnitTrackingCatalogProfile + { + Key = "traccar-forwarder", + ManufacturerKey = "traccar", + ManufacturerName = "Traccar", + Model = "Position Forwarding", + TransportType = UnitTrackingTransportType.ProtocolGateway, + ProtocolKey = "traccar", + PayloadAdapterKey = "traccar-json-v1", + CertificationStatus = UnitTrackingCertificationStatus.Candidate, + IdentifierRequired = true, + IsSelectable = false, + SupportedAuthModes = new[] + { + UnitTrackingAuthMode.Bearer, + UnitTrackingAuthMode.Basic, + UnitTrackingAuthMode.CustomHeader + }, + SetupSummary = + "The pinned Traccar forwarding adapter is delivered in WP5. Candidate profiles cannot be selected in production.", + RetryExpectation = + "Configure position-forwarding retries for 429 and 503 responses." + } + }; + + public Task> GetProfilesAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Profiles); + } + + public Task GetProfileAsync( + string profileKey, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var profile = Profiles.FirstOrDefault(item => + string.Equals(item.Key, profileKey?.Trim(), StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(profile); + } + } +} diff --git a/Core/Resgrid.Services/UnitTrackingEventIdService.cs b/Core/Resgrid.Services/UnitTrackingEventIdService.cs new file mode 100644 index 000000000..c22c34fab --- /dev/null +++ b/Core/Resgrid.Services/UnitTrackingEventIdService.cs @@ -0,0 +1,32 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class UnitTrackingEventIdService : IUnitTrackingEventIdService + { + private const int MaximumCallerEventIdLength = 256; + + public string CreateForHttps(string unitTrackingDeviceId, string callerEventId) + { + if (string.IsNullOrWhiteSpace(unitTrackingDeviceId)) + throw new ArgumentNullException(nameof(unitTrackingDeviceId)); + if (string.IsNullOrWhiteSpace(callerEventId)) + throw new ArgumentNullException(nameof(callerEventId)); + + var normalizedCallerEventId = callerEventId.Trim(); + if (normalizedCallerEventId.Length > MaximumCallerEventIdLength) + { + throw new ArgumentOutOfRangeException( + nameof(callerEventId), + $"Tracking event identifiers cannot exceed {MaximumCallerEventIdLength} characters."); + } + + var input = $"https|{unitTrackingDeviceId.Trim()}|{normalizedCallerEventId}"; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))) + .ToLowerInvariant(); + } + } +} diff --git a/Core/Resgrid.Services/UnitTrackingIdentifierService.cs b/Core/Resgrid.Services/UnitTrackingIdentifierService.cs new file mode 100644 index 000000000..20db90a0b --- /dev/null +++ b/Core/Resgrid.Services/UnitTrackingIdentifierService.cs @@ -0,0 +1,41 @@ +using System; +using System.Text; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class UnitTrackingIdentifierService : IUnitTrackingIdentifierService + { + private const int MaximumIdentifierLength = 128; + + public string Normalize(string identifier) + { + if (string.IsNullOrWhiteSpace(identifier)) + return null; + + var normalized = identifier + .Trim() + .Normalize(NormalizationForm.FormKC) + .ToUpperInvariant(); + + if (normalized.Length > MaximumIdentifierLength) + throw new ArgumentOutOfRangeException( + nameof(identifier), + $"Tracking identifiers cannot exceed {MaximumIdentifierLength} characters."); + + return normalized; + } + + public string Mask(string identifier) + { + var normalized = Normalize(identifier); + if (normalized == null) + return null; + + if (normalized.Length <= 4) + return new string('*', normalized.Length); + + return new string('*', normalized.Length - 4) + normalized.Substring(normalized.Length - 4); + } + } +} diff --git a/Core/Resgrid.Services/UnitTrackingIngressService.cs b/Core/Resgrid.Services/UnitTrackingIngressService.cs new file mode 100644 index 000000000..17782c078 --- /dev/null +++ b/Core/Resgrid.Services/UnitTrackingIngressService.cs @@ -0,0 +1,379 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.Tracking; + +namespace Resgrid.Services +{ + public class UnitTrackingIngressService : IUnitTrackingIngressService + { + private const int MaximumAlarmCodeLength = 64; + private const int MaximumEventIdLength = 256; + + private readonly IUnitLocationEventProvider _unitLocationEventProvider; + private readonly IUnitTrackingDevicesRepository _devicesRepository; + private readonly IUnitTrackingEventIdService _eventIdService; + private readonly IUnitTrackingIdentifierService _identifierService; + private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly IUnitsService _unitsService; + + public UnitTrackingIngressService( + IUnitLocationEventProvider unitLocationEventProvider, + IUnitTrackingDevicesRepository devicesRepository, + IUnitTrackingEventIdService eventIdService, + IUnitTrackingIdentifierService identifierService, + IDepartmentSettingsService departmentSettingsService, + IUnitsService unitsService) + { + _unitLocationEventProvider = unitLocationEventProvider; + _devicesRepository = devicesRepository; + _eventIdService = eventIdService; + _identifierService = identifierService; + _departmentSettingsService = departmentSettingsService; + _unitsService = unitsService; + } + + public async Task AcceptAsync( + AuthenticatedTrackingSource source, + IReadOnlyCollection positions, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var receivedOn = GetReceivedOn(positions); + + if (!IsEnabled(source?.Device)) + return Invalid(receivedOn, "The tracking binding is not enabled."); + + var device = source.Device; + var unit = await _unitsService.GetUnitByIdAsync(device.UnitId); + if (unit == null || unit.DepartmentId != device.DepartmentId) + { + await TryUpdateDeviceStatusAsync(device, receivedOn, null, "tenant-binding-invalid", cancellationToken); + return Invalid(receivedOn, "The tracking binding is invalid."); + } + + if (!IdentifierMatches(device.DeviceIdentifier, source.ReportedDeviceIdentifier)) + { + await TryUpdateDeviceStatusAsync(device, receivedOn, null, "identifier-mismatch", cancellationToken); + return Invalid(receivedOn, "The reported device identifier does not match the binding."); + } + + if (positions == null || positions.Count == 0) + { + await TryUpdateDeviceStatusAsync(device, receivedOn, null, "empty-payload", cancellationToken); + return Invalid(receivedOn, "At least one position is required."); + } + + if (positions.Count > Math.Max(1, UnitTrackingConfig.MaxBatchPositions)) + { + await TryUpdateDeviceStatusAsync(device, receivedOn, null, "batch-limit", cancellationToken); + return Invalid(receivedOn, "The position batch exceeds the configured limit."); + } + + var retentionDays = + await _departmentSettingsService.GetHardwareTrackingLocationRetentionDaysAsync(device.DepartmentId); + var normalization = NormalizeAll(positions, retentionDays); + if (normalization.Errors.Count > 0) + { + await TryUpdateDeviceStatusAsync(device, receivedOn, null, "invalid-payload", cancellationToken); + return new TrackingIngressResult + { + Status = TrackingIngressStatus.Invalid, + ReceivedOn = receivedOn, + Errors = normalization.Errors + }; + } + + var events = normalization.Positions + .Where(position => position.IsValidFix) + .Select(position => BuildEvent(device, position)) + .ToList(); + + if (events.Count > 0) + { + var published = await _unitLocationEventProvider.EnqueueUnitLocationEventsAsync(events, cancellationToken); + if (!published) + { + await TryUpdateDeviceStatusAsync( + device, + receivedOn, + normalization.Positions, + "queue-unavailable", + cancellationToken); + return new TrackingIngressResult + { + Status = TrackingIngressStatus.Unavailable, + ReceivedOn = receivedOn + }; + } + } + + await TryUpdateDeviceStatusAsync( + device, + receivedOn, + normalization.Positions, + null, + cancellationToken); + + return new TrackingIngressResult + { + Status = TrackingIngressStatus.Accepted, + Accepted = events.Count, + DuplicatesPossible = false, + ReceivedOn = receivedOn + }; + } + + private NormalizationResult NormalizeAll( + IReadOnlyCollection positions, + int retentionDays) + { + var normalized = new List(positions.Count); + var errors = new List(); + var index = 0; + + foreach (var position in positions) + { + var itemErrors = new List(); + var item = Normalize(position, Math.Max(1, retentionDays), itemErrors); + if (itemErrors.Count > 0) + errors.AddRange(itemErrors.Select(error => $"positions[{index}]: {error}")); + else + normalized.Add(item); + + index++; + } + + return new NormalizationResult(normalized, errors); + } + + private CanonicalTrackingPosition Normalize( + CanonicalTrackingPosition position, + int retentionDays, + ICollection errors) + { + if (position == null) + { + errors.Add("Position is required."); + return null; + } + + if (string.IsNullOrWhiteSpace(position.EventId)) + errors.Add("eventId is required."); + else if (position.EventId.Trim().Length > MaximumEventIdLength) + errors.Add($"eventId cannot exceed {MaximumEventIdLength} characters."); + + if (position.Latitude < -90m || position.Latitude > 90m) + errors.Add("latitude must be between -90 and 90."); + if (position.Longitude < -180m || position.Longitude > 180m) + errors.Add("longitude must be between -180 and 180."); + + var receivedOn = EnsureUtc( + position.ReceivedOnUtc == default ? DateTime.UtcNow : position.ReceivedOnUtc); + var timestampSource = position.TimestampSource; + var timestamp = position.TimestampUtc; + if (timestamp == default) + { + timestamp = receivedOn; + timestampSource = TrackingTimestampSource.Server; + } + else + { + timestamp = EnsureUtc(timestamp); + if (timestampSource == TrackingTimestampSource.Unknown) + timestampSource = TrackingTimestampSource.Device; + } + + if (timestamp > receivedOn.AddSeconds(Math.Max(0, UnitTrackingConfig.MaxFutureSkewSeconds))) + errors.Add("timestamp is too far in the future."); + if (timestamp < receivedOn.AddDays(-retentionDays)) + errors.Add("timestamp is outside the configured retention window."); + + var alarmCode = string.IsNullOrWhiteSpace(position.AlarmCode) + ? null + : position.AlarmCode.Trim(); + if (alarmCode?.Length > MaximumAlarmCodeLength) + errors.Add($"alarmCode cannot exceed {MaximumAlarmCodeLength} characters."); + + return new CanonicalTrackingPosition + { + EventId = position.EventId?.Trim(), + TimestampUtc = timestamp, + ReceivedOnUtc = receivedOn, + Latitude = position.Latitude, + Longitude = position.Longitude, + AccuracyMeters = NonNegative(position.AccuracyMeters), + AltitudeMeters = position.AltitudeMeters, + SpeedMetersPerSecond = NonNegative(position.SpeedMetersPerSecond), + HeadingDegrees = NormalizeHeading(position.HeadingDegrees), + Satellites = NonNegative(position.Satellites), + Hdop = NonNegative(position.Hdop), + BatteryPercent = Percentage(position.BatteryPercent), + ExternalPowerVolts = NonNegative(position.ExternalPowerVolts), + SignalPercent = Percentage(position.SignalPercent), + Ignition = position.Ignition, + IsMoving = position.IsMoving, + AlarmCode = alarmCode, + TimestampSource = timestampSource, + IsValidFix = position.IsValidFix + }; + } + + private UnitLocationEvent BuildEvent( + UnitTrackingDevice device, + CanonicalTrackingPosition position) + { + return new UnitLocationEvent + { + EventId = _eventIdService.CreateForHttps(device.UnitTrackingDeviceId, position.EventId), + DepartmentId = device.DepartmentId, + UnitId = device.UnitId, + Timestamp = position.TimestampUtc, + ReceivedOn = position.ReceivedOnUtc, + Latitude = position.Latitude, + Longitude = position.Longitude, + Accuracy = position.AccuracyMeters, + Altitude = position.AltitudeMeters, + Speed = position.SpeedMetersPerSecond, + Heading = position.HeadingDegrees, + SourceType = (int)UnitLocationSourceType.HardwareTracker, + SourceId = device.UnitTrackingDeviceId, + SourcePriority = device.SourcePriority, + TransportType = device.TransportType, + ProtocolKey = device.ProtocolKey, + IsValidFix = position.IsValidFix, + Satellites = position.Satellites, + Hdop = position.Hdop, + BatteryPercent = position.BatteryPercent, + ExternalPowerVolts = position.ExternalPowerVolts, + SignalPercent = position.SignalPercent, + Ignition = position.Ignition, + IsMoving = position.IsMoving, + AlarmCode = position.AlarmCode, + TimestampSource = (int)position.TimestampSource + }; + } + + private async Task TryUpdateDeviceStatusAsync( + UnitTrackingDevice device, + DateTime receivedOn, + IReadOnlyCollection positions, + string errorCode, + CancellationToken cancellationToken) + { + try + { + device.LastSeenOn = receivedOn; + device.LastErrorCode = errorCode; + device.LastStatus = errorCode == null + ? (int)UnitTrackingDeviceStatus.Online + : (int)UnitTrackingDeviceStatus.Error; + + var validPositions = positions? + .Where(position => position.IsValidFix) + .ToList(); + if (validPositions?.Count > 0) + { + device.LastPositionOn = validPositions.Max(position => position.TimestampUtc); + device.LastReceivedOn = validPositions.Max(position => position.ReceivedOnUtc); + } + + await _devicesRepository.UpdateAsync(device, cancellationToken); + } + catch (Exception ex) + { + Logging.LogException(ex, "Unable to update tracking device status metadata."); + } + } + + private bool IdentifierMatches(string configured, string reported) + { + if (string.IsNullOrWhiteSpace(reported)) + return true; + + try + { + var normalizedReported = _identifierService.Normalize(reported); + var normalizedConfigured = _identifierService.Normalize(configured); + return normalizedConfigured != null && + string.Equals(normalizedConfigured, normalizedReported, StringComparison.Ordinal); + } + catch (ArgumentException) + { + return false; + } + } + + private static bool IsEnabled(UnitTrackingDevice device) => + device != null && device.IsEnabled && !device.IsDeleted; + + private static TrackingIngressResult Invalid(DateTime receivedOn, string error) => + new() + { + Status = TrackingIngressStatus.Invalid, + ReceivedOn = receivedOn, + Errors = new[] { error } + }; + + private static DateTime GetReceivedOn(IReadOnlyCollection positions) + { + var receivedOn = positions? + .Where(position => position != null && position.ReceivedOnUtc != default) + .Select(position => EnsureUtc(position.ReceivedOnUtc)) + .DefaultIfEmpty(DateTime.UtcNow) + .Min() ?? DateTime.UtcNow; + return receivedOn; + } + + private static DateTime EnsureUtc(DateTime value) => + value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc) + }; + + private static decimal? NonNegative(decimal? value) => + value.HasValue && value.Value >= 0m ? value : null; + + private static int? NonNegative(int? value) => + value.HasValue && value.Value >= 0 ? value : null; + + private static decimal? Percentage(decimal? value) => + value.HasValue && value.Value >= 0m && value.Value <= 100m ? value : null; + + private static int? Percentage(int? value) => + value.HasValue && value.Value >= 0 && value.Value <= 100 ? value : null; + + private static decimal? NormalizeHeading(decimal? heading) + { + if (!heading.HasValue) + return null; + + return ((heading.Value % 360m) + 360m) % 360m; + } + + private sealed class NormalizationResult + { + public NormalizationResult( + IReadOnlyCollection positions, + IReadOnlyCollection errors) + { + Positions = positions; + Errors = errors; + } + + public IReadOnlyCollection Positions { get; } + public IReadOnlyCollection Errors { get; } + } + } +} diff --git a/Core/Resgrid.Services/UnitTrackingService.cs b/Core/Resgrid.Services/UnitTrackingService.cs new file mode 100644 index 000000000..79d99fa6e --- /dev/null +++ b/Core/Resgrid.Services/UnitTrackingService.cs @@ -0,0 +1,713 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class UnitTrackingService : IUnitTrackingService + { + private readonly IUnitTrackingDevicesRepository _devicesRepository; + private readonly IUnitTrackingCredentialsRepository _credentialsRepository; + private readonly IUnitTrackingAuthenticationService _authenticationService; + private readonly IUnitTrackingIdentifierService _identifierService; + private readonly IUnitsService _unitsService; + private readonly IEventAggregator _eventAggregator; + private readonly IUnitOfWork _unitOfWork; + + public UnitTrackingService( + IUnitTrackingDevicesRepository devicesRepository, + IUnitTrackingCredentialsRepository credentialsRepository, + IUnitTrackingAuthenticationService authenticationService, + IUnitTrackingIdentifierService identifierService, + IUnitsService unitsService, + IEventAggregator eventAggregator, + IUnitOfWork unitOfWork) + { + _devicesRepository = devicesRepository; + _credentialsRepository = credentialsRepository; + _authenticationService = authenticationService; + _identifierService = identifierService; + _unitsService = unitsService; + _eventAggregator = eventAggregator; + _unitOfWork = unitOfWork; + } + + public async Task GetDeviceByIdAsync(string deviceId, int departmentId) + { + var device = await _devicesRepository.GetByIdAsync(deviceId); + return device != null && device.DepartmentId == departmentId && !device.IsDeleted + ? device + : null; + } + + public async Task> GetDevicesForDepartmentAsync(int departmentId) + { + var devices = await _devicesRepository.GetAllByDepartmentIdAsync(departmentId); + return devices?.Where(device => !device.IsDeleted).ToList() ?? new List(); + } + + public async Task> GetDevicesForUnitAsync(int departmentId, int unitId) + { + var devices = await _devicesRepository.GetAllByUnitIdAsync(departmentId, unitId); + return devices?.Where(device => !device.IsDeleted).ToList() ?? new List(); + } + + public async Task> GetCredentialsForDeviceAsync(string deviceId, int departmentId) + { + await GetOwnedDeviceAsync(deviceId, departmentId, includeDeleted: false); + var credentials = await _credentialsRepository.GetAllByDeviceIdAsync(deviceId); + return credentials?.Select(SanitizeCredential).ToList() ?? new List(); + } + + public async Task CreateDeviceAsync( + UnitTrackingDevice device, + int departmentId, + string userId, + CancellationToken cancellationToken = default) + { + if (device == null) + throw new ArgumentNullException(nameof(device)); + + ValidateUserAndDepartment(userId, departmentId); + await ValidateUnitOwnershipAsync(device.UnitId, departmentId); + + var now = DateTime.UtcNow; + var created = new UnitTrackingDevice + { + UnitTrackingDeviceId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + UnitId = device.UnitId, + DisplayName = TrimToNull(device.DisplayName), + ManufacturerKey = NormalizeKey(device.ManufacturerKey), + ModelKey = NormalizeKey(device.ModelKey), + TransportType = ValidateTransportType(device.TransportType), + ProtocolKey = NormalizeKey(device.ProtocolKey), + PayloadAdapterKey = NormalizeKey(device.PayloadAdapterKey), + DeviceIdentifier = _identifierService.Normalize(device.DeviceIdentifier), + SecondaryIdentifier = _identifierService.Normalize(device.SecondaryIdentifier), + IsEnabled = device.IsEnabled, + IsDeleted = false, + SourcePriority = device.SourcePriority, + AllowedSourceCidrs = TrimToNull(device.AllowedSourceCidrs), + LastStatus = device.IsEnabled + ? (int)UnitTrackingDeviceStatus.NeverSeen + : (int)UnitTrackingDeviceStatus.Disabled, + FirmwareVersion = TrimToNull(device.FirmwareVersion), + CreatedByUserId = userId, + CreatedOn = now + }; + + var saved = await _devicesRepository.InsertAsync(created, cancellationToken); + await _authenticationService.InvalidateDeviceAsync(saved); + PublishAudit(departmentId, userId, AuditLogTypes.UnitTrackingDeviceCreated, null, DeviceAuditSnapshot(saved)); + return saved; + } + + public async Task UpdateDeviceAsync( + UnitTrackingDevice device, + int departmentId, + string userId, + CancellationToken cancellationToken = default) + { + if (device == null) + throw new ArgumentNullException(nameof(device)); + + ValidateUserAndDepartment(userId, departmentId); + var existing = await GetOwnedDeviceAsync(device.UnitTrackingDeviceId, departmentId, includeDeleted: false); + + if (device.UnitId != existing.UnitId) + { + throw new InvalidOperationException( + "A physical tracker must be rebound by creating a new binding; UnitId cannot be edited in place."); + } + + if (existing.IsEnabled && !device.IsEnabled) + return await DisableDeviceAsync(existing.UnitTrackingDeviceId, departmentId, userId, cancellationToken); + + await ValidateUnitOwnershipAsync(existing.UnitId, departmentId); + var credentials = await GetCredentialEntitiesAsync(existing.UnitTrackingDeviceId); + var before = DeviceAuditSnapshot(existing); + var oldCacheIdentity = CopyCacheIdentity(existing); + + existing.DisplayName = TrimToNull(device.DisplayName); + existing.ManufacturerKey = NormalizeKey(device.ManufacturerKey); + existing.ModelKey = NormalizeKey(device.ModelKey); + existing.TransportType = ValidateTransportType(device.TransportType); + existing.ProtocolKey = NormalizeKey(device.ProtocolKey); + existing.PayloadAdapterKey = NormalizeKey(device.PayloadAdapterKey); + existing.DeviceIdentifier = _identifierService.Normalize(device.DeviceIdentifier); + existing.SecondaryIdentifier = _identifierService.Normalize(device.SecondaryIdentifier); + existing.IsEnabled = device.IsEnabled; + existing.SourcePriority = device.SourcePriority; + existing.AllowedSourceCidrs = TrimToNull(device.AllowedSourceCidrs); + existing.FirmwareVersion = TrimToNull(device.FirmwareVersion); + existing.UpdatedByUserId = userId; + existing.UpdatedOn = DateTime.UtcNow; + if (device.IsEnabled && existing.LastStatus == (int)UnitTrackingDeviceStatus.Disabled) + existing.LastStatus = (int)UnitTrackingDeviceStatus.NeverSeen; + + var saved = await _devicesRepository.UpdateAsync(existing, cancellationToken); + await InvalidateDeviceAndCredentialsAsync(oldCacheIdentity, saved, credentials); + PublishAudit(departmentId, userId, AuditLogTypes.UnitTrackingDeviceUpdated, before, DeviceAuditSnapshot(saved)); + return saved; + } + + public Task DisableDeviceAsync( + string deviceId, + int departmentId, + string userId, + CancellationToken cancellationToken = default) + { + return DisableOrDeleteDeviceAsync(deviceId, departmentId, userId, false, cancellationToken); + } + + public Task DeleteDeviceAsync( + string deviceId, + int departmentId, + string userId, + CancellationToken cancellationToken = default) + { + return DisableOrDeleteDeviceAsync(deviceId, departmentId, userId, true, cancellationToken); + } + + public async Task RebindDeviceAsync( + string deviceId, + int departmentId, + int newUnitId, + string userId, + CancellationToken cancellationToken = default) + { + ValidateUserAndDepartment(userId, departmentId); + var existing = await GetOwnedDeviceAsync(deviceId, departmentId, includeDeleted: false); + if (existing.UnitId == newUnitId) + throw new InvalidOperationException("The tracking device is already bound to the requested Unit."); + + await ValidateUnitOwnershipAsync(newUnitId, departmentId); + var credentials = await GetCredentialEntitiesAsync(deviceId); + var before = DeviceAuditSnapshot(existing); + var oldCacheIdentity = CopyCacheIdentity(existing); + var now = DateTime.UtcNow; + + _unitOfWork.CreateOrGetConnection(); + UnitTrackingDevice replacement; + try + { + existing.IsEnabled = false; + existing.IsDeleted = true; + existing.LastStatus = (int)UnitTrackingDeviceStatus.Disabled; + existing.UpdatedByUserId = userId; + existing.UpdatedOn = now; + await _devicesRepository.UpdateAsync(existing, cancellationToken); + await RevokeCredentialsAsync(credentials, now, cancellationToken); + + replacement = CopyForRebind(existing, newUnitId, userId, now); + await _devicesRepository.InsertAsync(replacement, cancellationToken); + _unitOfWork.CommitChanges(); + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + + await InvalidateDeviceAndCredentialsAsync(oldCacheIdentity, replacement, credentials); + PublishAudit(departmentId, userId, AuditLogTypes.UnitTrackingDeviceDeleted, before, DeviceAuditSnapshot(existing)); + PublishAudit(departmentId, userId, AuditLogTypes.UnitTrackingDeviceCreated, null, DeviceAuditSnapshot(replacement)); + return replacement; + } + + public async Task CreateCredentialAsync( + string deviceId, + int departmentId, + UnitTrackingAuthMode authMode, + string userId, + string headerName = null, + string basicUsername = null, + CancellationToken cancellationToken = default) + { + ValidateUserAndDepartment(userId, departmentId); + var device = await GetOwnedDeviceAsync(deviceId, departmentId, includeDeleted: false); + if (!device.IsEnabled) + throw new InvalidOperationException("Credentials cannot be created for a disabled tracking device."); + + ValidateAuthMode(authMode, headerName, basicUsername); + var generated = _authenticationService.GenerateCredential(); + var now = DateTime.UtcNow; + var credential = new UnitTrackingCredential + { + UnitTrackingCredentialId = Guid.NewGuid().ToString(), + UnitTrackingDeviceId = deviceId, + AuthMode = (int)authMode, + HeaderName = authMode == UnitTrackingAuthMode.CustomHeader ? headerName.Trim() : null, + BasicUsername = authMode == UnitTrackingAuthMode.Basic ? basicUsername.Trim() : null, + KeyPrefix = generated.KeyPrefix, + SecretHash = generated.SecretHash, + ValidFrom = now, + CreatedByUserId = userId, + CreatedOn = now + }; + + var saved = await _credentialsRepository.InsertAsync(credential, cancellationToken); + await _authenticationService.InvalidateCredentialAsync(saved.SecretHash); + await _authenticationService.InvalidateDeviceAsync(device); + PublishAudit( + departmentId, + userId, + AuditLogTypes.UnitTrackingCredentialCreated, + null, + CredentialAuditSnapshot(saved)); + + return BuildProvisioningResult(device, saved, generated.Token); + } + + public async Task RotateCredentialAsync( + string deviceId, + string credentialId, + int departmentId, + string userId, + TimeSpan? overlap = null, + CancellationToken cancellationToken = default) + { + ValidateUserAndDepartment(userId, departmentId); + var device = await GetOwnedDeviceAsync(deviceId, departmentId, includeDeleted: false); + if (!device.IsEnabled) + throw new InvalidOperationException("Credentials cannot be rotated for a disabled tracking device."); + + var existing = await GetOwnedCredentialAsync(credentialId, deviceId); + if (existing.RevokedOn.HasValue) + throw new InvalidOperationException("A revoked tracking credential cannot be rotated."); + + var rotationOverlap = overlap ?? + TimeSpan.FromHours(Math.Max(0, UnitTrackingConfig.CredentialRotationOverlapHours)); + if (rotationOverlap < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(overlap)); + + var generated = _authenticationService.GenerateCredential(); + var now = DateTime.UtcNow; + var replacement = new UnitTrackingCredential + { + UnitTrackingCredentialId = Guid.NewGuid().ToString(), + UnitTrackingDeviceId = deviceId, + AuthMode = existing.AuthMode, + HeaderName = existing.HeaderName, + BasicUsername = existing.BasicUsername, + KeyPrefix = generated.KeyPrefix, + SecretHash = generated.SecretHash, + ValidFrom = now, + CreatedByUserId = userId, + CreatedOn = now + }; + var before = CredentialAuditSnapshot(existing); + + _unitOfWork.CreateOrGetConnection(); + try + { + existing.ExpiresOn = now.Add(rotationOverlap); + await _credentialsRepository.UpdateAsync(existing, cancellationToken); + await _credentialsRepository.InsertAsync(replacement, cancellationToken); + _unitOfWork.CommitChanges(); + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + + await _authenticationService.InvalidateCredentialAsync(existing.SecretHash); + await _authenticationService.InvalidateCredentialAsync(replacement.SecretHash); + await _authenticationService.InvalidateDeviceAsync(device); + PublishAudit( + departmentId, + userId, + AuditLogTypes.UnitTrackingCredentialRotated, + before, + new + { + Previous = CredentialAuditSnapshot(existing), + Current = CredentialAuditSnapshot(replacement) + }); + + return BuildProvisioningResult(device, replacement, generated.Token); + } + + public async Task RevokeCredentialAsync( + string deviceId, + string credentialId, + int departmentId, + string userId, + CancellationToken cancellationToken = default) + { + ValidateUserAndDepartment(userId, departmentId); + var device = await GetOwnedDeviceAsync(deviceId, departmentId, includeDeleted: false); + var credential = await GetOwnedCredentialAsync(credentialId, deviceId); + var before = CredentialAuditSnapshot(credential); + + if (!credential.RevokedOn.HasValue) + { + credential.RevokedOn = DateTime.UtcNow; + await _credentialsRepository.UpdateAsync(credential, cancellationToken); + } + + await _authenticationService.InvalidateCredentialAsync(credential.SecretHash); + await _authenticationService.InvalidateDeviceAsync(device); + PublishAudit( + departmentId, + userId, + AuditLogTypes.UnitTrackingCredentialRevoked, + before, + CredentialAuditSnapshot(credential)); + + return SanitizeCredential(credential); + } + + private async Task DisableOrDeleteDeviceAsync( + string deviceId, + int departmentId, + string userId, + bool delete, + CancellationToken cancellationToken) + { + ValidateUserAndDepartment(userId, departmentId); + var device = await GetOwnedDeviceAsync(deviceId, departmentId, includeDeleted: false); + var before = DeviceAuditSnapshot(device); + var oldCacheIdentity = CopyCacheIdentity(device); + var credentials = await GetCredentialEntitiesAsync(deviceId); + var now = DateTime.UtcNow; + + _unitOfWork.CreateOrGetConnection(); + try + { + device.IsEnabled = false; + device.IsDeleted = delete; + device.LastStatus = (int)UnitTrackingDeviceStatus.Disabled; + device.UpdatedByUserId = userId; + device.UpdatedOn = now; + await _devicesRepository.UpdateAsync(device, cancellationToken); + await RevokeCredentialsAsync(credentials, now, cancellationToken); + _unitOfWork.CommitChanges(); + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + + await InvalidateDeviceAndCredentialsAsync(oldCacheIdentity, device, credentials); + PublishAudit( + departmentId, + userId, + delete ? AuditLogTypes.UnitTrackingDeviceDeleted : AuditLogTypes.UnitTrackingDeviceDisabled, + before, + DeviceAuditSnapshot(device)); + + return device; + } + + private async Task GetOwnedDeviceAsync( + string deviceId, + int departmentId, + bool includeDeleted) + { + if (string.IsNullOrWhiteSpace(deviceId)) + throw new ArgumentNullException(nameof(deviceId)); + + var device = await _devicesRepository.GetByIdAsync(deviceId); + if (device == null || device.DepartmentId != departmentId || (!includeDeleted && device.IsDeleted)) + throw new InvalidOperationException("The tracking device was not found for this department."); + + return device; + } + + private async Task GetOwnedCredentialAsync(string credentialId, string deviceId) + { + if (string.IsNullOrWhiteSpace(credentialId)) + throw new ArgumentNullException(nameof(credentialId)); + + var credential = await _credentialsRepository.GetByIdAsync(credentialId); + if (credential == null || + !string.Equals( + credential.UnitTrackingDeviceId, + deviceId, + StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The tracking credential was not found for this device."); + + return credential; + } + + private async Task> GetCredentialEntitiesAsync(string deviceId) + { + var credentials = await _credentialsRepository.GetAllByDeviceIdAsync(deviceId); + return credentials?.ToList() ?? new List(); + } + + private async Task ValidateUnitOwnershipAsync(int unitId, int departmentId) + { + if (unitId <= 0) + throw new ArgumentOutOfRangeException(nameof(unitId)); + + var unit = await _unitsService.GetUnitByIdAsync(unitId); + if (unit == null || unit.DepartmentId != departmentId) + throw new InvalidOperationException("The selected Unit was not found for this department."); + } + + private async Task RevokeCredentialsAsync( + IEnumerable credentials, + DateTime revokedOn, + CancellationToken cancellationToken) + { + foreach (var credential in credentials.Where(item => !item.RevokedOn.HasValue)) + { + credential.RevokedOn = revokedOn; + await _credentialsRepository.UpdateAsync(credential, cancellationToken); + } + } + + private async Task InvalidateDeviceAndCredentialsAsync( + UnitTrackingDevice oldIdentity, + UnitTrackingDevice currentIdentity, + IEnumerable credentials) + { + await _authenticationService.InvalidateDeviceAsync(oldIdentity); + await _authenticationService.InvalidateDeviceAsync(currentIdentity); + + foreach (var credential in credentials) + await _authenticationService.InvalidateCredentialAsync(credential.SecretHash); + } + + private void PublishAudit( + int departmentId, + string userId, + AuditLogTypes type, + object before, + object after) + { + _eventAggregator.SendMessage(new AuditEvent + { + DepartmentId = departmentId, + UserId = userId, + Type = type, + Before = before == null ? null : JsonConvert.SerializeObject(before), + After = after == null ? null : JsonConvert.SerializeObject(after), + Successful = true, + ServerName = Environment.MachineName + }); + } + + private object DeviceAuditSnapshot(UnitTrackingDevice device) + { + if (device == null) + return null; + + return new + { + device.UnitTrackingDeviceId, + device.DepartmentId, + device.UnitId, + device.DisplayName, + device.ManufacturerKey, + device.ModelKey, + device.TransportType, + device.ProtocolKey, + device.PayloadAdapterKey, + DeviceIdentifier = _identifierService.Mask(device.DeviceIdentifier), + SecondaryIdentifier = _identifierService.Mask(device.SecondaryIdentifier), + device.IsEnabled, + device.IsDeleted, + device.SourcePriority, + device.LastStatus, + device.LastErrorCode, + device.FirmwareVersion, + device.CreatedByUserId, + device.CreatedOn, + device.UpdatedByUserId, + device.UpdatedOn + }; + } + + private static object CredentialAuditSnapshot(UnitTrackingCredential credential) + { + if (credential == null) + return null; + + return new + { + credential.UnitTrackingCredentialId, + credential.UnitTrackingDeviceId, + credential.AuthMode, + credential.HeaderName, + credential.BasicUsername, + credential.KeyPrefix, + credential.ValidFrom, + credential.ExpiresOn, + credential.RevokedOn, + credential.LastUsedOn, + credential.CreatedByUserId, + credential.CreatedOn + }; + } + + private static UnitTrackingCredential SanitizeCredential(UnitTrackingCredential credential) + { + return new UnitTrackingCredential + { + UnitTrackingCredentialId = credential.UnitTrackingCredentialId, + UnitTrackingDeviceId = credential.UnitTrackingDeviceId, + AuthMode = credential.AuthMode, + HeaderName = credential.HeaderName, + BasicUsername = credential.BasicUsername, + KeyPrefix = credential.KeyPrefix, + ValidFrom = credential.ValidFrom, + ExpiresOn = credential.ExpiresOn, + RevokedOn = credential.RevokedOn, + LastUsedOn = credential.LastUsedOn, + CreatedByUserId = credential.CreatedByUserId, + CreatedOn = credential.CreatedOn + }; + } + + private static UnitTrackingCredentialProvisionResult BuildProvisioningResult( + UnitTrackingDevice device, + UnitTrackingCredential credential, + string token) + { + var mode = (UnitTrackingAuthMode)credential.AuthMode; + var relativeEndpoint = mode == UnitTrackingAuthMode.CapabilityPath + ? $"/api/v4/unit-trackers/c/{Uri.EscapeDataString(token)}" + : $"/api/v4/unit-trackers/{Uri.EscapeDataString(device.UnitTrackingDeviceId)}/positions"; + var configuredBaseUrl = UnitTrackingConfig.PublicHttpsBaseUrl?.Trim().TrimEnd('/'); + var baseUrl = Uri.TryCreate(configuredBaseUrl, UriKind.Absolute, out var publicUri) && + string.Equals(publicUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + ? configuredBaseUrl + : null; + var result = new UnitTrackingCredentialProvisionResult + { + Credential = SanitizeCredential(credential), + Token = token, + EndpointUrl = string.IsNullOrWhiteSpace(baseUrl) + ? relativeEndpoint + : baseUrl + relativeEndpoint + }; + + switch (mode) + { + case UnitTrackingAuthMode.Bearer: + result.HeaderName = "Authorization"; + result.HeaderValue = $"Bearer {token}"; + break; + case UnitTrackingAuthMode.Basic: + result.HeaderName = "Authorization"; + result.BasicUsername = credential.BasicUsername; + result.HeaderValue = "Basic " + Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{credential.BasicUsername}:{token}")); + break; + case UnitTrackingAuthMode.CustomHeader: + result.HeaderName = credential.HeaderName; + result.HeaderValue = token; + break; + } + + return result; + } + + private static UnitTrackingDevice CopyCacheIdentity(UnitTrackingDevice device) + { + return new UnitTrackingDevice + { + UnitTrackingDeviceId = device.UnitTrackingDeviceId, + ProtocolKey = device.ProtocolKey, + DeviceIdentifier = device.DeviceIdentifier + }; + } + + private static UnitTrackingDevice CopyForRebind( + UnitTrackingDevice device, + int newUnitId, + string userId, + DateTime now) + { + return new UnitTrackingDevice + { + UnitTrackingDeviceId = Guid.NewGuid().ToString(), + DepartmentId = device.DepartmentId, + UnitId = newUnitId, + DisplayName = device.DisplayName, + ManufacturerKey = device.ManufacturerKey, + ModelKey = device.ModelKey, + TransportType = device.TransportType, + ProtocolKey = device.ProtocolKey, + PayloadAdapterKey = device.PayloadAdapterKey, + DeviceIdentifier = device.DeviceIdentifier, + SecondaryIdentifier = device.SecondaryIdentifier, + IsEnabled = true, + IsDeleted = false, + SourcePriority = device.SourcePriority, + AllowedSourceCidrs = device.AllowedSourceCidrs, + LastStatus = (int)UnitTrackingDeviceStatus.NeverSeen, + FirmwareVersion = device.FirmwareVersion, + CreatedByUserId = userId, + CreatedOn = now + }; + } + + private static int ValidateTransportType(int transportType) + { + if (!Enum.IsDefined(typeof(UnitTrackingTransportType), transportType) || + transportType == (int)UnitTrackingTransportType.Unknown) + throw new ArgumentOutOfRangeException(nameof(transportType)); + + return transportType; + } + + private static void ValidateAuthMode( + UnitTrackingAuthMode authMode, + string headerName, + string basicUsername) + { + if (!Enum.IsDefined(typeof(UnitTrackingAuthMode), authMode) || + authMode == UnitTrackingAuthMode.Unknown) + throw new ArgumentOutOfRangeException(nameof(authMode)); + + if (authMode == UnitTrackingAuthMode.CustomHeader && string.IsNullOrWhiteSpace(headerName)) + throw new ArgumentNullException(nameof(headerName)); + if (authMode == UnitTrackingAuthMode.CustomHeader && + (headerName.Length > 128 || !headerName.All(IsHttpTokenCharacter))) + throw new ArgumentException("The custom credential header name is invalid.", nameof(headerName)); + + if (authMode == UnitTrackingAuthMode.Basic && string.IsNullOrWhiteSpace(basicUsername)) + throw new ArgumentNullException(nameof(basicUsername)); + if (authMode == UnitTrackingAuthMode.Basic && basicUsername.Trim().Length > 128) + throw new ArgumentOutOfRangeException( + nameof(basicUsername), + "Basic authentication usernames cannot exceed 128 characters."); + } + + private static bool IsHttpTokenCharacter(char character) => + char.IsLetterOrDigit(character) || + "!#$%&'*+-.^_`|~".Contains(character); + + private static void ValidateUserAndDepartment(string userId, int departmentId) + { + if (departmentId <= 0) + throw new ArgumentOutOfRangeException(nameof(departmentId)); + if (string.IsNullOrWhiteSpace(userId)) + throw new ArgumentNullException(nameof(userId)); + } + + private static string NormalizeKey(string value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + + private static string TrimToNull(string value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/Core/Resgrid.Services/UnitTrackingStatusService.cs b/Core/Resgrid.Services/UnitTrackingStatusService.cs new file mode 100644 index 000000000..5cd4c7ca0 --- /dev/null +++ b/Core/Resgrid.Services/UnitTrackingStatusService.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class UnitTrackingStatusService : IUnitTrackingStatusService + { + private readonly IDepartmentSettingsService _departmentSettingsService; + + public UnitTrackingStatusService(IDepartmentSettingsService departmentSettingsService) + { + _departmentSettingsService = departmentSettingsService; + } + + public async Task GetEffectiveStatusAsync( + UnitTrackingDevice device, + DateTime? utcNow = null, + CancellationToken cancellationToken = default) + { + if (device == null) + throw new ArgumentNullException(nameof(device)); + + cancellationToken.ThrowIfCancellationRequested(); + if (!device.IsEnabled || device.IsDeleted) + return UnitTrackingDeviceStatus.Disabled; + if (device.LastStatus == (int)UnitTrackingDeviceStatus.Error) + return UnitTrackingDeviceStatus.Error; + + var lastConnectivity = + device.LastReceivedOn.HasValue && device.LastSeenOn.HasValue + ? (device.LastReceivedOn.Value > device.LastSeenOn.Value + ? device.LastReceivedOn + : device.LastSeenOn) + : device.LastReceivedOn ?? device.LastSeenOn; + if (!lastConnectivity.HasValue) + return UnitTrackingDeviceStatus.NeverSeen; + + var staleAfterSeconds = + await _departmentSettingsService.GetHardwareTrackingStaleAfterSecondsAsync( + device.DepartmentId); + var now = utcNow ?? DateTime.UtcNow; + return lastConnectivity.Value < now.AddSeconds(-Math.Max(1, staleAfterSeconds)) + ? UnitTrackingDeviceStatus.Stale + : UnitTrackingDeviceStatus.Online; + } + } +} diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs index 363aa0713..9e9beb36a 100644 --- a/Core/Resgrid.Services/UnitsService.cs +++ b/Core/Resgrid.Services/UnitsService.cs @@ -22,6 +22,7 @@ public class UnitsService : IUnitsService private readonly IUnitStateRoleRepository _unitStateRoleRepository; private readonly Lazy> _unitLocationRepository; private readonly IUnitLocationsDocRepository _unitLocationsDocRepository; + private readonly Lazy _unitLocationsMongoRepository; private readonly ISubscriptionsService _subscriptionsService; private readonly IUserStateService _userStateService; private readonly IEventAggregator _eventAggregator; @@ -35,7 +36,8 @@ public UnitsService(IUnitsRepository unitsRepository, IUnitStatesRepository unit IUnitLogsRepository unitLogsRepository, IUnitTypesRepository unitTypesRepository, ISubscriptionsService subscriptionsService, IUnitRolesRepository unitRolesRepository, IUnitStateRoleRepository unitStateRoleRepository, IUserStateService userStateService, IEventAggregator eventAggregator, ICustomStateService customStateService, Lazy> unitLocationRepository, - IUnitLocationsDocRepository unitLocationsDocRepository, IUnitActiveRolesRepository unitActiveRolesRepository, + IUnitLocationsDocRepository unitLocationsDocRepository, Lazy unitLocationsMongoRepository, + IUnitActiveRolesRepository unitActiveRolesRepository, IDepartmentGroupsService departmentGroupsService, ILimitsService limitsService, IPersonnelRolesService personnelRolesService) { _unitsRepository = unitsRepository; @@ -50,6 +52,7 @@ public UnitsService(IUnitsRepository unitsRepository, IUnitStatesRepository unit _customStateService = customStateService; _unitLocationRepository = unitLocationRepository; _unitLocationsDocRepository = unitLocationsDocRepository; + _unitLocationsMongoRepository = unitLocationsMongoRepository; _unitActiveRolesRepository = unitActiveRolesRepository; _departmentGroupsService = departmentGroupsService; _limitsService = limitsService; @@ -539,40 +542,41 @@ where callEnabledStates.Contains(us.State) return unitStates; } - public async Task AddUnitLocationAsync(UnitsLocation location, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) + public async Task AddUnitLocationAsync(UnitsLocation location, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) { - try + if (location == null) + throw new ArgumentNullException(nameof(location)); + + UnitLocationWriteResult result; + + if (Config.DataConfig.DocDatabaseType == Config.DatabaseTypes.Postgres) { - if (Config.DataConfig.DocDatabaseType == Config.DatabaseTypes.Postgres) - { - if (String.IsNullOrWhiteSpace(location.PgId)) - location = await _unitLocationsDocRepository.InsertAsync(location); - else - location = await _unitLocationsDocRepository.UpdateAsync(location); - } + if (String.IsNullOrWhiteSpace(location.PgId)) + result = await _unitLocationsDocRepository.InsertAsync(location); else - { - if (location.Id.Timestamp == 0) - await _unitLocationRepository.Value.InsertOneAsync(location); - else - await _unitLocationRepository.Value.ReplaceOneAsync(location); - } - - _eventAggregator.SendMessage(new UnitLocationUpdatedEvent() - { - DepartmentId = departmentId, - UnitId = location.UnitId.ToString(), - Latitude = double.Parse(location.Latitude.ToString()), - Longitude = double.Parse(location.Longitude.ToString()), - RecordId = location.GetId(), - }); + result = await _unitLocationsDocRepository.UpdateAsync(location); } - catch (Exception ex) + else + { + if (location.Id.Timestamp == 0) + result = await _unitLocationsMongoRepository.Value.InsertAsync(location); + else + result = await _unitLocationsMongoRepository.Value.UpdateAsync(location); + } + + if (result.Status == UnitLocationWriteStatus.Inserted) { - Framework.Logging.LogException(ex); + await _eventAggregator.SendMessageAsync(new UnitLocationUpdatedEvent + { + DepartmentId = departmentId, + UnitId = result.Location.UnitId.ToString(), + Latitude = (double)result.Location.Latitude, + Longitude = (double)result.Location.Longitude, + RecordId = result.Location.GetId(), + }); } - return location; + return result; } public async Task GetLatestUnitLocationAsync(int unitId, DateTime? timestamp = null) diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs index d8e0a0856..07772dbb1 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs @@ -139,6 +139,51 @@ await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.UnitL autoDelete: false, arguments: null); + var unitLocationQueueV2Name = SetQueueNameForEnv(ServiceBusConfig.UnitLocationQueueV2Name); + var unitLocationRetryQueueV2Name = SetQueueNameForEnv(ServiceBusConfig.UnitLocationRetryQueueV2Name); + var unitLocationDeadQueueV2Name = SetQueueNameForEnv(ServiceBusConfig.UnitLocationDeadQueueV2Name); + var queueExchange = ServiceBusConfig.RabbbitExchange ?? string.Empty; + var queueTtlMilliseconds = (long)Math.Max(1, UnitTrackingConfig.QueueMessageTtlSeconds) * 1000L; + var retryTtlMilliseconds = (long)Math.Max(1, UnitTrackingConfig.UnitLocationRetryDelaySeconds) * 1000L; + + await channel.QueueDeclareAsync( + queue: unitLocationDeadQueueV2Name, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null); + + await channel.QueueDeclareAsync( + queue: unitLocationRetryQueueV2Name, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new System.Collections.Generic.Dictionary + { + ["x-message-ttl"] = retryTtlMilliseconds, + ["x-dead-letter-exchange"] = queueExchange, + ["x-dead-letter-routing-key"] = unitLocationQueueV2Name + }); + + await channel.QueueDeclareAsync( + queue: unitLocationQueueV2Name, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new System.Collections.Generic.Dictionary + { + ["x-message-ttl"] = queueTtlMilliseconds, + ["x-dead-letter-exchange"] = queueExchange, + ["x-dead-letter-routing-key"] = unitLocationDeadQueueV2Name + }); + + if (!string.IsNullOrWhiteSpace(queueExchange)) + { + await channel.QueueBindAsync(unitLocationQueueV2Name, queueExchange, unitLocationQueueV2Name); + await channel.QueueBindAsync(unitLocationRetryQueueV2Name, queueExchange, unitLocationRetryQueueV2Name); + await channel.QueueBindAsync(unitLocationDeadQueueV2Name, queueExchange, unitLocationDeadQueueV2Name); + } + await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.PersonnelLoactionQueueName), durable: false, exclusive: false, diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundQueueProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundQueueProvider.cs index c294638eb..f883e3400 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundQueueProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundQueueProvider.cs @@ -16,6 +16,7 @@ public class RabbitInboundQueueProvider { private string _clientName; private IChannel _channel; + private IChannel _unitLocationChannel; public Func CallQueueReceived; public Func MessageQueueReceived; public Func DistributionListQueueReceived; @@ -43,6 +44,16 @@ public async Task Start(string clientName) if (connection != null) { _channel = await connection.CreateChannelAsync(); + + if (UnitLocationEventQueueReceived != null) + { + _unitLocationChannel = await connection.CreateChannelAsync(); + var prefetchCount = (ushort)Math.Min( + ushort.MaxValue, + Math.Max(1, UnitTrackingConfig.UnitLocationQueuePrefetchCount)); + await _unitLocationChannel.BasicQosAsync(0, prefetchCount, false); + } + await StartMonitoring(); } } @@ -438,44 +449,8 @@ private async Task StartMonitoring() if (UnitLocationEventQueueReceived != null) { - var unitLocationQueueReceivedConsumer = new AsyncEventingBasicConsumer(_channel); - unitLocationQueueReceivedConsumer.ReceivedAsync += async (model, ea) => - { - if (ea != null && ea.Body.Length > 0) - { - UnitLocationEvent unitLocation = null; - try - { - var body = ea.Body; - var message = Encoding.UTF8.GetString(body.ToArray()); - unitLocation = ObjectSerialization.Deserialize(message); - } - catch (Exception ex) - { - Logging.LogException(ex, Encoding.UTF8.GetString(ea.Body.ToArray())); - } - - try - { - if (unitLocation != null) - { - if (UnitLocationEventQueueReceived != null) - { - await UnitLocationEventQueueReceived.Invoke(unitLocation); - } - } - } - catch (Exception ex) - { - Logging.LogException(ex); - } - } - }; - - String unitLocationEventQueueReceivedConsumerTag = await _channel.BasicConsumeAsync( - queue: RabbitConnection.SetQueueNameForEnv(ServiceBusConfig.UnitLoactionQueueName), - autoAck: true, - consumer: unitLocationQueueReceivedConsumer); + await StartUnitLocationConsumer(ServiceBusConfig.UnitLoactionQueueName); + await StartUnitLocationConsumer(ServiceBusConfig.UnitLocationQueueV2Name); } if (PersonnelLocationEventQueueReceived != null) @@ -659,10 +634,118 @@ await _channel.BasicConsumeAsync( public bool IsConnected() { - if (_channel == null) + if (_channel == null || (UnitLocationEventQueueReceived != null && _unitLocationChannel == null)) return false; - return _channel.IsOpen; + return _channel.IsOpen && (_unitLocationChannel?.IsOpen ?? true); + } + + private async Task StartUnitLocationConsumer(string queueName) + { + var consumer = new AsyncEventingBasicConsumer(_unitLocationChannel); + consumer.ReceivedAsync += async (model, ea) => + { + if (ea == null) + return; + + try + { + if (ea.Body.Length == 0) + throw new InvalidOperationException("Unit location queue message body is empty."); + + var message = Encoding.UTF8.GetString(ea.Body.ToArray()); + var unitLocation = ObjectSerialization.Deserialize(message) + ?? throw new InvalidOperationException("Unit location queue message could not be deserialized."); + + await UnitLocationEventQueueReceived.Invoke(unitLocation); + await _unitLocationChannel.BasicAckAsync(ea.DeliveryTag, false); + } + catch (Exception ex) + { + Logging.LogException(ex); + + if (await RetryOrDeadLetterUnitLocationAsync(ea, ex)) + await _unitLocationChannel.BasicAckAsync(ea.DeliveryTag, false); + else + await _unitLocationChannel.BasicNackAsync(ea.DeliveryTag, false, true); + } + }; + + await _unitLocationChannel.BasicConsumeAsync( + queue: RabbitConnection.SetQueueNameForEnv(queueName), + autoAck: false, + consumer: consumer); + } + + private async Task RetryOrDeadLetterUnitLocationAsync(BasicDeliverEventArgs ea, Exception exception) + { + var retryCount = GetRetryCount(ea.BasicProperties?.Headers); + var maxRetryAttempts = Math.Max(0, UnitTrackingConfig.UnitLocationMaxRetryAttempts); + var targetQueue = retryCount >= maxRetryAttempts + ? ServiceBusConfig.UnitLocationDeadQueueV2Name + : ServiceBusConfig.UnitLocationRetryQueueV2Name; + + try + { + var connection = await RabbitConnection.CreateConnection(_clientName); + if (connection == null) + return false; + + var channelOptions = new CreateChannelOptions(true, true); + await using var channel = await connection.CreateChannelAsync(channelOptions); + var properties = new BasicProperties + { + DeliveryMode = DeliveryModes.Persistent, + MessageId = ea.BasicProperties?.MessageId, + Headers = new Dictionary + { + ["x-unitlocation-retry-count"] = retryCount + 1, + ["x-previous-error"] = exception.GetType().Name + } + }; + + using var publishTimeout = new System.Threading.CancellationTokenSource( + TimeSpan.FromSeconds(Math.Max(1, UnitTrackingConfig.QueuePublishTimeoutSeconds))); + + await channel.BasicPublishAsync( + exchange: ServiceBusConfig.RabbbitExchange, + routingKey: RabbitConnection.SetQueueNameForEnv(targetQueue), + mandatory: true, + basicProperties: properties, + body: ea.Body, + cancellationToken: publishTimeout.Token); + + return true; + } + catch (Exception ex) + { + Logging.LogException(ex); + return false; + } + } + + private static int GetRetryCount(IDictionary headers) + { + if (headers == null || !headers.TryGetValue("x-unitlocation-retry-count", out var value)) + return 0; + + switch (value) + { + case byte byteValue: + return byteValue; + case sbyte signedByteValue: + return Math.Max(0, (int)signedByteValue); + case short shortValue: + return Math.Max(0, (int)shortValue); + case int intValue: + return Math.Max(0, intValue); + case long longValue: + return (int)Math.Max(0, Math.Min(int.MaxValue, longValue)); + case byte[] bytes when int.TryParse(Encoding.UTF8.GetString(bytes), out var parsed): + return Math.Max(0, parsed); + default: + return int.TryParse(value.ToString(), out var converted) ? Math.Max(0, converted) : 0; + } } private async Task RetryQueueItem(BasicDeliverEventArgs ea, Exception mex) diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs index 6e240813b..d416c5db3 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs @@ -7,7 +7,9 @@ using Resgrid.Model; using Resgrid.Model.Providers; using System.Collections.Generic; +using System.Linq; using Resgrid.Model.Events; +using System.Threading; using System.Threading.Tasks; namespace Resgrid.Providers.Bus.Rabbit @@ -90,7 +92,30 @@ public async Task EnqueueUnitLocationEvent(UnitLocationEvent unitLocationE { var serializedObject = ObjectSerialization.Serialize(unitLocationEvent); - return await SendMessage(ServiceBusConfig.UnitLoactionQueueName, serializedObject, false, "300000"); + var expiration = ((long)Math.Max(1, UnitTrackingConfig.QueueMessageTtlSeconds) * 1000L).ToString(); + return await SendMessage(ServiceBusConfig.UnitLocationQueueV2Name, serializedObject, true, expiration, true); + } + + public async Task EnqueueUnitLocationEvents( + IReadOnlyCollection unitLocationEvents, + CancellationToken cancellationToken = default) + { + if (unitLocationEvents == null) + throw new ArgumentNullException(nameof(unitLocationEvents)); + if (unitLocationEvents.Count == 0) + return true; + + var serializedMessages = unitLocationEvents + .Select(ObjectSerialization.Serialize) + .ToList(); + var expiration = + ((long)Math.Max(1, UnitTrackingConfig.QueueMessageTtlSeconds) * 1000L).ToString(); + + return await SendMessagesWithConfirmation( + ServiceBusConfig.UnitLocationQueueV2Name, + serializedMessages, + expiration, + cancellationToken); } public async Task EnqueuePersonnelLocationEvent(PersonnelLocationEvent personnelLocationEvent) @@ -114,7 +139,8 @@ public async Task EnqueueWorkflowEvent(Resgrid.Model.Queue.WorkflowQueueIt return await SendMessage(ServiceBusConfig.WorkflowQueueName, serializedObject); } - private async Task SendMessage(string queueName, string message, bool durable = true, string expiration = "36000000") + private async Task SendMessage(string queueName, string message, bool durable = true, string expiration = "36000000", + bool requirePublisherConfirmation = false) { if (String.IsNullOrWhiteSpace(queueName)) throw new ArgumentNullException("queueName"); @@ -131,7 +157,13 @@ private async Task SendMessage(string queueName, string message, bool dura // v7 IChannel skips the async Channel.Close/CloseOk handshake that releases the channel // number back to the SessionManager, leaking channels until the connection hits its limit // (ChannelAllocationException: "The connection cannot support any more channels"). - await using (var channel = await connection.CreateChannelAsync()) + var channelOptions = requirePublisherConfirmation + ? new CreateChannelOptions(true, true) + : null; + + await using (var channel = channelOptions == null + ? await connection.CreateChannelAsync() + : await connection.CreateChannelAsync(channelOptions)) { if (channel != null) { @@ -148,11 +180,18 @@ private async Task SendMessage(string queueName, string message, bool dura props.Expiration = expiration; - await channel.BasicPublishAsync(exchange: ServiceBusConfig.RabbbitExchange, - routingKey: RabbitConnection.SetQueueNameForEnv(queueName), - mandatory: true, - basicProperties: props, - body: Encoding.ASCII.GetBytes(message)); + using var publishTimeout = requirePublisherConfirmation + ? new System.Threading.CancellationTokenSource( + TimeSpan.FromSeconds(Math.Max(1, UnitTrackingConfig.QueuePublishTimeoutSeconds))) + : null; + + await channel.BasicPublishAsync( + exchange: ServiceBusConfig.RabbbitExchange, + routingKey: RabbitConnection.SetQueueNameForEnv(queueName), + mandatory: true, + basicProperties: props, + body: Encoding.ASCII.GetBytes(message), + cancellationToken: publishTimeout?.Token ?? default); return true; } @@ -176,6 +215,65 @@ await channel.BasicPublishAsync(exchange: ServiceBusConfig.RabbbitExchange, } } + private async Task SendMessagesWithConfirmation( + string queueName, + IReadOnlyCollection messages, + string expiration, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(queueName)) + throw new ArgumentNullException(nameof(queueName)); + if (messages == null || messages.Count == 0) + return true; + if (messages.Any(string.IsNullOrWhiteSpace)) + throw new ArgumentException("Queue messages cannot be empty.", nameof(messages)); + + try + { + var connection = await RabbitConnection.CreateConnection(_clientName); + if (connection == null) + { + Logging.LogError("RabbitOutboundQueueProvider->SendMessagesWithConfirmation connection is null."); + return false; + } + + await using var channel = + await connection.CreateChannelAsync(new CreateChannelOptions(true, true), cancellationToken); + var props = new BasicProperties + { + DeliveryMode = DeliveryModes.Persistent, + Expiration = expiration, + Headers = new Dictionary + { + ["x-redelivered-count"] = 0 + } + }; + + using var publishTimeout = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + publishTimeout.CancelAfter( + TimeSpan.FromSeconds(Math.Max(1, UnitTrackingConfig.QueuePublishTimeoutSeconds))); + + foreach (var message in messages) + { + await channel.BasicPublishAsync( + exchange: ServiceBusConfig.RabbbitExchange, + routingKey: RabbitConnection.SetQueueNameForEnv(queueName), + mandatory: true, + basicProperties: props, + body: Encoding.ASCII.GetBytes(message), + cancellationToken: publishTimeout.Token); + } + + return true; + } + catch (Exception ex) + { + Logging.LogException(ex); + return false; + } + } + public async Task VerifyAndCreateClients() { return await RabbitConnection.VerifyAndCreateClients(_clientName); diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs index 9285e949d..360c28f01 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs @@ -128,7 +128,7 @@ public async Task UnitLocationUpdatedChanged(UnitLocationUpdatedEvent mess DepartmentId = message.DepartmentId, ItemId = message.RecordId, Payload = JsonConvert.SerializeObject(message) - }.SerializeJson()); + }.SerializeJson(), true); } private static async Task VerifyAndCreateClients(string clientName) @@ -161,27 +161,40 @@ private static async Task VerifyAndCreateClients(string clientName) return true; } - private async Task SendMessage(string topicName, string message) + private async Task SendMessage(string topicName, string message, bool requirePublisherConfirmation = false) { - await VerifyAndCreateClients(_clientName); + if (!await VerifyAndCreateClients(_clientName)) + return false; try { var connection = await RabbitConnection.CreateConnection(_clientName); - if (connection != null) + if (connection == null) + return false; + + var channelOptions = requirePublisherConfirmation + ? new CreateChannelOptions(true, true) + : null; + await using (var channel = channelOptions == null + ? await connection.CreateChannelAsync() + : await connection.CreateChannelAsync(channelOptions)) { - // await using so the channel is closed via DisposeAsync(): the synchronous Dispose() on a - // v7 IChannel skips the async Channel.Close/CloseOk handshake that releases the channel - // number back to the SessionManager, leaking channels until the connection hits its limit - // (ChannelAllocationException: "The connection cannot support any more channels"). - await using (var channel = await connection.CreateChannelAsync()) - { - await channel.BasicPublishAsync(exchange: RabbitConnection.SetQueueNameForEnv(topicName), - routingKey: "", - //mandatory: false, //TODO: Not sure here. -SJ - //basicProperties: null, - body: Encoding.ASCII.GetBytes(message)); - } + using var publishTimeout = requirePublisherConfirmation + ? new System.Threading.CancellationTokenSource( + TimeSpan.FromSeconds(Math.Max(1, UnitTrackingConfig.QueuePublishTimeoutSeconds))) + : null; + await channel.BasicPublishAsync( + exchange: RabbitConnection.SetQueueNameForEnv(topicName), + routingKey: "", + mandatory: false, + basicProperties: new BasicProperties + { + DeliveryMode = requirePublisherConfirmation + ? DeliveryModes.Persistent + : DeliveryModes.Transient + }, + body: Encoding.ASCII.GetBytes(message), + cancellationToken: publishTimeout?.Token ?? default); } return true; diff --git a/Providers/Resgrid.Providers.Bus/BusModule.cs b/Providers/Resgrid.Providers.Bus/BusModule.cs index a435589a4..ada6ceb05 100644 --- a/Providers/Resgrid.Providers.Bus/BusModule.cs +++ b/Providers/Resgrid.Providers.Bus/BusModule.cs @@ -14,6 +14,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // Singletons builder.RegisterType().As().SingleInstance(); diff --git a/Providers/Resgrid.Providers.Bus/EventAggregator.cs b/Providers/Resgrid.Providers.Bus/EventAggregator.cs index 1c4d55da3..462b29651 100644 --- a/Providers/Resgrid.Providers.Bus/EventAggregator.cs +++ b/Providers/Resgrid.Providers.Bus/EventAggregator.cs @@ -1,16 +1,21 @@ using System; using Easy.MessageHub; using Resgrid.Model.Providers; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; namespace Resgrid.Providers.Bus { public class EventAggregator: IEventAggregator { private readonly IMessageHub _hub; + private readonly ConcurrentDictionary>> _asyncListeners; public EventAggregator() { _hub = new MessageHub(); + _asyncListeners = new ConcurrentDictionary>>(); } public void SendMessage(TMessage message) @@ -18,13 +23,41 @@ public void SendMessage(TMessage message) _hub.Publish(message); } + public async Task SendMessageAsync(TMessage message) + { + if (!_asyncListeners.TryGetValue(typeof(TMessage), out var listeners)) + return; + + await Task.WhenAll(listeners.Values.Select(listener => listener(message))); + } + public Guid AddListener(Action listener) { return _hub.Subscribe(listener); } + public Guid AddAsyncListener(Func listener) + { + if (listener == null) + throw new ArgumentNullException(nameof(listener)); + + var token = Guid.NewGuid(); + var listeners = _asyncListeners.GetOrAdd( + typeof(T), + _ => new ConcurrentDictionary>()); + listeners[token] = message => listener((T)message); + + return token; + } + public void RemoveListener(Guid token) { + foreach (var listeners in _asyncListeners.Values) + { + if (listeners.TryRemove(token, out _)) + return; + } + if (_hub.IsSubscribed(token)) _hub.Unsubscribe(token); } diff --git a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs index 805e11366..f4a535e7f 100644 --- a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs @@ -58,7 +58,7 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro _eventAggregator.AddListener(callClosedTopicHandler); _eventAggregator.AddListener(incidentCommandUpdatedTopicHandler); _eventAggregator.AddListener(personnelLocationUpdatedTopicHandler); - _eventAggregator.AddListener(unitLocationUpdatedTopicHandler); + _eventAggregator.AddAsyncListener(unitLocationUpdatedTopicHandler); } public Action unitStatusHandler = async delegate (UnitStatusEvent message) @@ -610,12 +610,13 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro _rabbitTopicProvider.PersonnelLocationUnidatedChanged(message); }; - public Action unitLocationUpdatedTopicHandler = async delegate (UnitLocationUpdatedEvent message) + public Func unitLocationUpdatedTopicHandler = async delegate (UnitLocationUpdatedEvent message) { if (_rabbitTopicProvider == null) _rabbitTopicProvider = new RabbitTopicProvider(); - _rabbitTopicProvider.UnitLocationUpdatedChanged(message); + if (!await _rabbitTopicProvider.UnitLocationUpdatedChanged(message)) + throw new InvalidOperationException("Unable to publish the Unit location realtime update."); }; #endregion Topic Based Events } diff --git a/Providers/Resgrid.Providers.Bus/UnitLocationEventProvider.cs b/Providers/Resgrid.Providers.Bus/UnitLocationEventProvider.cs index b80013209..880cef6d6 100644 --- a/Providers/Resgrid.Providers.Bus/UnitLocationEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/UnitLocationEventProvider.cs @@ -1,27 +1,30 @@ using System.Threading.Tasks; using Resgrid.Model.Events; using Resgrid.Model.Providers; -using Resgrid.Providers.Bus.Rabbit; namespace Resgrid.Providers.Bus { public class UnitLocationEventProvider : IUnitLocationEventProvider { - private RabbitOutboundQueueProvider _rabbitOutboundQueueProvider; + private readonly IRabbitOutboundQueueProvider _rabbitOutboundQueueProvider; - public UnitLocationEventProvider() + public UnitLocationEventProvider(IRabbitOutboundQueueProvider rabbitOutboundQueueProvider) { - + _rabbitOutboundQueueProvider = rabbitOutboundQueueProvider; } public async Task EnqueueUnitLocationEventAsync(UnitLocationEvent unitLocationEvent) { - if (_rabbitOutboundQueueProvider == null) - _rabbitOutboundQueueProvider = new RabbitOutboundQueueProvider(); - - _rabbitOutboundQueueProvider.EnqueueUnitLocationEvent(unitLocationEvent); - - return true; + return await _rabbitOutboundQueueProvider.EnqueueUnitLocationEvent(unitLocationEvent); + } + + public async Task EnqueueUnitLocationEventsAsync( + System.Collections.Generic.IReadOnlyCollection unitLocationEvents, + System.Threading.CancellationToken cancellationToken = default) + { + return await _rabbitOutboundQueueProvider.EnqueueUnitLocationEvents( + unitLocationEvents, + cancellationToken); } } } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0101_AddUnitTracking.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0101_AddUnitTracking.cs new file mode 100644 index 000000000..2f60dda85 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0101_AddUnitTracking.cs @@ -0,0 +1,144 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(101)] + public class M0101_AddUnitTracking : Migration + { + private const string DevicesTable = "UnitTrackingDevices"; + private const string CredentialsTable = "UnitTrackingCredentials"; + + public override void Up() + { + if (!Schema.Table(DevicesTable).Exists()) + { + Create.Table(DevicesTable) + .WithColumn("UnitTrackingDeviceId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("UnitId").AsInt32().NotNullable() + .WithColumn("DisplayName").AsString(200).Nullable() + .WithColumn("ManufacturerKey").AsString(64).Nullable() + .WithColumn("ModelKey").AsString(64).Nullable() + .WithColumn("TransportType").AsInt32().NotNullable() + .WithColumn("ProtocolKey").AsString(64).Nullable() + .WithColumn("PayloadAdapterKey").AsString(64).Nullable() + .WithColumn("DeviceIdentifier").AsString(128).Nullable() + .WithColumn("SecondaryIdentifier").AsString(128).Nullable() + .WithColumn("IsEnabled").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("SourcePriority").AsInt32().NotNullable().WithDefaultValue(100) + .WithColumn("AllowedSourceCidrs").AsString(int.MaxValue).Nullable() + .WithColumn("LastSeenOn").AsDateTime().Nullable() + .WithColumn("LastPositionOn").AsDateTime().Nullable() + .WithColumn("LastReceivedOn").AsDateTime().Nullable() + .WithColumn("LastStatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("LastErrorCode").AsString(64).Nullable() + .WithColumn("FirmwareVersion").AsString(128).Nullable() + .WithColumn("CreatedByUserId").AsString(128).NotNullable() + .WithColumn("CreatedOn").AsDateTime().NotNullable() + .WithColumn("UpdatedByUserId").AsString(128).Nullable() + .WithColumn("UpdatedOn").AsDateTime().Nullable(); + + Create.ForeignKey("FK_UnitTrackingDevices_Departments") + .FromTable(DevicesTable).ForeignColumn("DepartmentId") + .ToTable("Departments").PrimaryColumn("DepartmentId"); + + Create.ForeignKey("FK_UnitTrackingDevices_Units") + .FromTable(DevicesTable).ForeignColumn("UnitId") + .ToTable("Units").PrimaryColumn("UnitId"); + } + + if (!Schema.Table(DevicesTable).Index("IX_UnitTrackingDevices_Department_Unit_Deleted").Exists()) + { + Create.Index("IX_UnitTrackingDevices_Department_Unit_Deleted") + .OnTable(DevicesTable) + .OnColumn("DepartmentId").Ascending() + .OnColumn("UnitId").Ascending() + .OnColumn("IsDeleted").Ascending(); + } + + if (!Schema.Table(DevicesTable).Index("IX_UnitTrackingDevices_Department_Enabled_Deleted").Exists()) + { + Create.Index("IX_UnitTrackingDevices_Department_Enabled_Deleted") + .OnTable(DevicesTable) + .OnColumn("DepartmentId").Ascending() + .OnColumn("IsEnabled").Ascending() + .OnColumn("IsDeleted").Ascending(); + } + + if (!Schema.Table(DevicesTable).Index("IX_UnitTrackingDevices_LastSeenOn").Exists()) + { + Create.Index("IX_UnitTrackingDevices_LastSeenOn") + .OnTable(DevicesTable) + .OnColumn("LastSeenOn").Ascending(); + } + + Execute.Sql(@" + IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE name = 'UX_UnitTrackingDevices_Protocol_DeviceIdentifier' + AND object_id = OBJECT_ID(N'UnitTrackingDevices')) + BEGIN + CREATE UNIQUE NONCLUSTERED INDEX UX_UnitTrackingDevices_Protocol_DeviceIdentifier + ON UnitTrackingDevices (ProtocolKey, DeviceIdentifier) + WHERE DeviceIdentifier IS NOT NULL AND IsDeleted = 0; + END"); + + if (!Schema.Table(CredentialsTable).Exists()) + { + Create.Table(CredentialsTable) + .WithColumn("UnitTrackingCredentialId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("UnitTrackingDeviceId").AsString(128).NotNullable() + .WithColumn("AuthMode").AsInt32().NotNullable() + .WithColumn("HeaderName").AsString(128).Nullable() + .WithColumn("BasicUsername").AsString(128).Nullable() + .WithColumn("KeyPrefix").AsString(20).NotNullable() + .WithColumn("SecretHash").AsString(64).NotNullable() + .WithColumn("ValidFrom").AsDateTime().NotNullable() + .WithColumn("ExpiresOn").AsDateTime().Nullable() + .WithColumn("RevokedOn").AsDateTime().Nullable() + .WithColumn("LastUsedOn").AsDateTime().Nullable() + .WithColumn("CreatedByUserId").AsString(128).NotNullable() + .WithColumn("CreatedOn").AsDateTime().NotNullable(); + + Create.ForeignKey("FK_UnitTrackingCredentials_Devices") + .FromTable(CredentialsTable).ForeignColumn("UnitTrackingDeviceId") + .ToTable(DevicesTable).PrimaryColumn("UnitTrackingDeviceId"); + } + + if (!Schema.Table(CredentialsTable).Index("UX_UnitTrackingCredentials_SecretHash").Exists()) + { + Create.Index("UX_UnitTrackingCredentials_SecretHash") + .OnTable(CredentialsTable) + .OnColumn("SecretHash").Ascending() + .WithOptions().Unique(); + } + + if (!Schema.Table(CredentialsTable).Index("IX_UnitTrackingCredentials_Device_Revoked_Expires").Exists()) + { + Create.Index("IX_UnitTrackingCredentials_Device_Revoked_Expires") + .OnTable(CredentialsTable) + .OnColumn("UnitTrackingDeviceId").Ascending() + .OnColumn("RevokedOn").Ascending() + .OnColumn("ExpiresOn").Ascending(); + } + + if (!Schema.Table(CredentialsTable).Index("IX_UnitTrackingCredentials_KeyPrefix").Exists()) + { + Create.Index("IX_UnitTrackingCredentials_KeyPrefix") + .OnTable(CredentialsTable) + .OnColumn("KeyPrefix").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table(CredentialsTable).Exists()) + Delete.Table(CredentialsTable); + + if (Schema.Table(DevicesTable).Exists()) + Delete.Table(DevicesTable); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs new file mode 100644 index 000000000..39a3deb07 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs @@ -0,0 +1,137 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(101)] + public class M0101_AddUnitTrackingPg : Migration + { + private const string DevicesTable = "unittrackingdevices"; + private const string CredentialsTable = "unittrackingcredentials"; + + public override void Up() + { + if (!Schema.Table(DevicesTable).Exists()) + { + Create.Table(DevicesTable) + .WithColumn("unittrackingdeviceid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("unitid").AsInt32().NotNullable() + .WithColumn("displayname").AsCustom("citext").Nullable() + .WithColumn("manufacturerkey").AsCustom("citext").Nullable() + .WithColumn("modelkey").AsCustom("citext").Nullable() + .WithColumn("transporttype").AsInt32().NotNullable() + .WithColumn("protocolkey").AsCustom("citext").Nullable() + .WithColumn("payloadadapterkey").AsCustom("citext").Nullable() + .WithColumn("deviceidentifier").AsCustom("citext").Nullable() + .WithColumn("secondaryidentifier").AsCustom("citext").Nullable() + .WithColumn("isenabled").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("sourcepriority").AsInt32().NotNullable().WithDefaultValue(100) + .WithColumn("allowedsourcecidrs").AsCustom("text").Nullable() + .WithColumn("lastseenon").AsDateTime2().Nullable() + .WithColumn("lastpositionon").AsDateTime2().Nullable() + .WithColumn("lastreceivedon").AsDateTime2().Nullable() + .WithColumn("laststatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("lasterrorcode").AsCustom("citext").Nullable() + .WithColumn("firmwareversion").AsCustom("citext").Nullable() + .WithColumn("createdbyuserid").AsCustom("citext").NotNullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("updatedbyuserid").AsCustom("citext").Nullable() + .WithColumn("updatedon").AsDateTime2().Nullable(); + + Create.ForeignKey("fk_unittrackingdevices_departments") + .FromTable(DevicesTable).ForeignColumn("departmentid") + .ToTable("departments").PrimaryColumn("departmentid"); + + Create.ForeignKey("fk_unittrackingdevices_units") + .FromTable(DevicesTable).ForeignColumn("unitid") + .ToTable("units").PrimaryColumn("unitid"); + } + + if (!Schema.Table(DevicesTable).Index("ix_unittrackingdevices_department_unit_deleted").Exists()) + { + Create.Index("ix_unittrackingdevices_department_unit_deleted") + .OnTable(DevicesTable) + .OnColumn("departmentid").Ascending() + .OnColumn("unitid").Ascending() + .OnColumn("isdeleted").Ascending(); + } + + if (!Schema.Table(DevicesTable).Index("ix_unittrackingdevices_department_enabled_deleted").Exists()) + { + Create.Index("ix_unittrackingdevices_department_enabled_deleted") + .OnTable(DevicesTable) + .OnColumn("departmentid").Ascending() + .OnColumn("isenabled").Ascending() + .OnColumn("isdeleted").Ascending(); + } + + if (!Schema.Table(DevicesTable).Index("ix_unittrackingdevices_lastseenon").Exists()) + { + Create.Index("ix_unittrackingdevices_lastseenon") + .OnTable(DevicesTable) + .OnColumn("lastseenon").Ascending(); + } + + Execute.Sql(@" + CREATE UNIQUE INDEX IF NOT EXISTS ux_unittrackingdevices_protocol_deviceidentifier + ON unittrackingdevices (COALESCE(protocolkey, ''), deviceidentifier) + WHERE deviceidentifier IS NOT NULL AND isdeleted = false;"); + + if (!Schema.Table(CredentialsTable).Exists()) + { + Create.Table(CredentialsTable) + .WithColumn("unittrackingcredentialid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("unittrackingdeviceid").AsCustom("citext").NotNullable() + .WithColumn("authmode").AsInt32().NotNullable() + .WithColumn("headername").AsCustom("citext").Nullable() + .WithColumn("basicusername").AsCustom("citext").Nullable() + .WithColumn("keyprefix").AsCustom("citext").NotNullable() + .WithColumn("secrethash").AsCustom("citext").NotNullable() + .WithColumn("validfrom").AsDateTime2().NotNullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("revokedon").AsDateTime2().Nullable() + .WithColumn("lastusedon").AsDateTime2().Nullable() + .WithColumn("createdbyuserid").AsCustom("citext").NotNullable() + .WithColumn("createdon").AsDateTime2().NotNullable(); + + Create.ForeignKey("fk_unittrackingcredentials_devices") + .FromTable(CredentialsTable).ForeignColumn("unittrackingdeviceid") + .ToTable(DevicesTable).PrimaryColumn("unittrackingdeviceid"); + } + + if (!Schema.Table(CredentialsTable).Index("ux_unittrackingcredentials_secrethash").Exists()) + { + Create.Index("ux_unittrackingcredentials_secrethash") + .OnTable(CredentialsTable) + .OnColumn("secrethash").Ascending() + .WithOptions().Unique(); + } + + if (!Schema.Table(CredentialsTable).Index("ix_unittrackingcredentials_device_revoked_expires").Exists()) + { + Create.Index("ix_unittrackingcredentials_device_revoked_expires") + .OnTable(CredentialsTable) + .OnColumn("unittrackingdeviceid").Ascending() + .OnColumn("revokedon").Ascending() + .OnColumn("expireson").Ascending(); + } + + if (!Schema.Table(CredentialsTable).Index("ix_unittrackingcredentials_keyprefix").Exists()) + { + Create.Index("ix_unittrackingcredentials_keyprefix") + .OnTable(CredentialsTable) + .OnColumn("keyprefix").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table(CredentialsTable).Exists()) + Delete.Table(CredentialsTable); + + if (Schema.Table(DevicesTable).Exists()) + Delete.Table(DevicesTable); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Sql/EF0003_PopulateDocDb.sql b/Providers/Resgrid.Providers.MigrationsPg/Sql/EF0003_PopulateDocDb.sql index 9ace3f60e..c9463177b 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Sql/EF0003_PopulateDocDb.sql +++ b/Providers/Resgrid.Providers.MigrationsPg/Sql/EF0003_PopulateDocDb.sql @@ -8,14 +8,38 @@ CREATE TABLE IF NOT EXISTS public.unitlocations( unitid integer NOT NULL, oid text, "timestamp" timestamp without time zone NOT NULL DEFAULT (now() AT TIME ZONE 'utc'::text), + eventid text, + receivedon timestamp without time zone, + sourcetype integer, + sourceid text, + sourcepriority integer NOT NULL DEFAULT 0, data jsonb NOT NULL ); +ALTER TABLE public.unitlocations ADD COLUMN IF NOT EXISTS eventid text; +ALTER TABLE public.unitlocations ADD COLUMN IF NOT EXISTS receivedon timestamp without time zone; +ALTER TABLE public.unitlocations ADD COLUMN IF NOT EXISTS sourcetype integer; +ALTER TABLE public.unitlocations ADD COLUMN IF NOT EXISTS sourceid text; +ALTER TABLE public.unitlocations ADD COLUMN IF NOT EXISTS sourcepriority integer NOT NULL DEFAULT 0; + IF NOT exists (select constraint_name from information_schema.table_constraints where table_name = 'unitlocations' and constraint_type = 'PRIMARY KEY') then ALTER TABLE public.unitlocations ADD PRIMARY KEY (id); END IF; +CREATE UNIQUE INDEX IF NOT EXISTS ux_unitlocations_eventid + ON public.unitlocations (eventid) + WHERE eventid IS NOT NULL; + +CREATE INDEX IF NOT EXISTS ix_unitlocations_department_unit_timestamp + ON public.unitlocations (departmentid, unitid, "timestamp" DESC); + +CREATE INDEX IF NOT EXISTS ix_unitlocations_department_unit_source_timestamp + ON public.unitlocations (departmentid, unitid, sourcetype, sourceid, "timestamp" DESC); + +CREATE INDEX IF NOT EXISTS ix_unitlocations_retention + ON public.unitlocations (departmentid, sourcetype, "timestamp"); + -- -- CREATE TABLE IF NOT EXISTS "public"."personnellocations" diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index d48a9cd00..347f7090c 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -62,6 +62,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingCredentialBySecretHashQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingCredentialBySecretHashQuery.cs new file mode 100644 index 000000000..5cfd3fdbf --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingCredentialBySecretHashQuery.cs @@ -0,0 +1,31 @@ +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository.Queries.UnitTracking +{ + public class SelectUnitTrackingCredentialBySecretHashQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + + public SelectUnitTrackingCredentialBySecretHashQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + if (DataConfig.DatabaseType == DatabaseTypes.Postgres) + { + return $@"SELECT * FROM {_sqlConfiguration.SchemaName}.unittrackingcredentials + WHERE secrethash = {_sqlConfiguration.ParameterNotation}SecretHash"; + } + + return $@"SELECT * FROM {_sqlConfiguration.SchemaName}.[UnitTrackingCredentials] + WHERE [SecretHash] = {_sqlConfiguration.ParameterNotation}SecretHash"; + } + + public string GetQuery() where TEntity : class, IEntity => GetQuery(); + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingCredentialsByDeviceQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingCredentialsByDeviceQuery.cs new file mode 100644 index 000000000..1d6836839 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingCredentialsByDeviceQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository.Queries.UnitTracking +{ + public class SelectUnitTrackingCredentialsByDeviceQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + + public SelectUnitTrackingCredentialsByDeviceQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + if (DataConfig.DatabaseType == DatabaseTypes.Postgres) + { + return $@"SELECT * FROM {_sqlConfiguration.SchemaName}.unittrackingcredentials + WHERE unittrackingdeviceid = {_sqlConfiguration.ParameterNotation}UnitTrackingDeviceId + ORDER BY createdon DESC"; + } + + return $@"SELECT * FROM {_sqlConfiguration.SchemaName}.[UnitTrackingCredentials] + WHERE [UnitTrackingDeviceId] = {_sqlConfiguration.ParameterNotation}UnitTrackingDeviceId + ORDER BY [CreatedOn] DESC"; + } + + public string GetQuery() where TEntity : class, IEntity => GetQuery(); + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingDeviceByProtocolIdentifierQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingDeviceByProtocolIdentifierQuery.cs new file mode 100644 index 000000000..3d123b0bb --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingDeviceByProtocolIdentifierQuery.cs @@ -0,0 +1,37 @@ +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository.Queries.UnitTracking +{ + public class SelectUnitTrackingDeviceByProtocolIdentifierQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + + public SelectUnitTrackingDeviceByProtocolIdentifierQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + if (DataConfig.DatabaseType == DatabaseTypes.Postgres) + { + return $@"SELECT * FROM {_sqlConfiguration.SchemaName}.unittrackingdevices + WHERE protocolkey = {_sqlConfiguration.ParameterNotation}ProtocolKey + AND deviceidentifier = {_sqlConfiguration.ParameterNotation}DeviceIdentifier + AND isenabled = true + AND isdeleted = false"; + } + + return $@"SELECT * FROM {_sqlConfiguration.SchemaName}.[UnitTrackingDevices] + WHERE [ProtocolKey] = {_sqlConfiguration.ParameterNotation}ProtocolKey + AND [DeviceIdentifier] = {_sqlConfiguration.ParameterNotation}DeviceIdentifier + AND [IsEnabled] = 1 + AND [IsDeleted] = 0"; + } + + public string GetQuery() where TEntity : class, IEntity => GetQuery(); + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingDevicesByUnitQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingDevicesByUnitQuery.cs new file mode 100644 index 000000000..f0662dc50 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/SelectUnitTrackingDevicesByUnitQuery.cs @@ -0,0 +1,35 @@ +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository.Queries.UnitTracking +{ + public class SelectUnitTrackingDevicesByUnitQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + + public SelectUnitTrackingDevicesByUnitQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + if (DataConfig.DatabaseType == DatabaseTypes.Postgres) + { + return $@"SELECT * FROM {_sqlConfiguration.SchemaName}.unittrackingdevices + WHERE departmentid = {_sqlConfiguration.ParameterNotation}DepartmentId + AND unitid = {_sqlConfiguration.ParameterNotation}UnitId + ORDER BY createdon DESC"; + } + + return $@"SELECT * FROM {_sqlConfiguration.SchemaName}.[UnitTrackingDevices] + WHERE [DepartmentId] = {_sqlConfiguration.ParameterNotation}DepartmentId + AND [UnitId] = {_sqlConfiguration.ParameterNotation}UnitId + ORDER BY [CreatedOn] DESC"; + } + + public string GetQuery() where TEntity : class, IEntity => GetQuery(); + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingCredentialsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingCredentialsRepository.cs new file mode 100644 index 000000000..e30134d93 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingCredentialsRepository.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries.UnitTracking; + +namespace Resgrid.Repositories.DataRepository +{ + public class UnitTrackingCredentialsRepository : RepositoryBase, IUnitTrackingCredentialsRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public UnitTrackingCredentialsRepository( + IConnectionProvider connectionProvider, + SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, + IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetAllByDeviceIdAsync(string unitTrackingDeviceId) + { + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("UnitTrackingDeviceId", unitTrackingDeviceId); + var query = _queryFactory.GetQuery(); + + return await WithConnectionAsync(connection => + connection.QueryAsync(query, parameters, _unitOfWork.Transaction)); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task GetBySecretHashAsync(string secretHash) + { + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("SecretHash", secretHash); + var query = _queryFactory.GetQuery(); + + return await WithConnectionAsync(connection => + connection.QuerySingleOrDefaultAsync( + query, + parameters, + _unitOfWork.Transaction)); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + private async Task WithConnectionAsync(Func> action) + { + if (_unitOfWork?.Connection != null) + return await action(_unitOfWork.CreateOrGetConnection()); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await action(connection); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingDevicesRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingDevicesRepository.cs new file mode 100644 index 000000000..ca5872d03 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingDevicesRepository.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries.UnitTracking; + +namespace Resgrid.Repositories.DataRepository +{ + public class UnitTrackingDevicesRepository : RepositoryBase, IUnitTrackingDevicesRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public UnitTrackingDevicesRepository( + IConnectionProvider connectionProvider, + SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, + IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetAllByUnitIdAsync(int departmentId, int unitId) + { + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("UnitId", unitId); + var query = _queryFactory.GetQuery(); + + return await WithConnectionAsync(connection => + connection.QueryAsync(query, parameters, _unitOfWork.Transaction)); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task GetByProtocolIdentifierAsync(string protocolKey, string deviceIdentifier) + { + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("ProtocolKey", protocolKey); + parameters.Add("DeviceIdentifier", deviceIdentifier); + var query = _queryFactory.GetQuery(); + + return await WithConnectionAsync(connection => + connection.QuerySingleOrDefaultAsync( + query, + parameters, + _unitOfWork.Transaction)); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + private async Task WithConnectionAsync(Func> action) + { + if (_unitOfWork?.Connection != null) + return await action(_unitOfWork.CreateOrGetConnection()); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await action(connection); + } + } +} diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/DocumentDbRepository.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/DocumentDbRepository.cs index d5990ad14..2807801e9 100644 --- a/Repositories/Resgrid.Repositories.NoSqlRepository/DocumentDbRepository.cs +++ b/Repositories/Resgrid.Repositories.NoSqlRepository/DocumentDbRepository.cs @@ -10,10 +10,23 @@ namespace Resgrid.Repositories.NoSqlRepository { public class DocumentDbRepository : IDocumentDbRepository { + private readonly Lazy _unitLocationsMongoRepository; + + public DocumentDbRepository(Lazy unitLocationsMongoRepository) + { + _unitLocationsMongoRepository = unitLocationsMongoRepository; + } + public async Task UpdateDocumentDatabaseAsync() { try { + if (DataConfig.DocDatabaseType == DatabaseTypes.MongoDb) + { + await _unitLocationsMongoRepository.Value.EnsureIndexesAsync(); + return true; + } + if (DataConfig.DocDatabaseType != DatabaseTypes.Postgres) return true; diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/NoSqlDataModule.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/NoSqlDataModule.cs index 085502ca9..a88748b06 100644 --- a/Repositories/Resgrid.Repositories.NoSqlRepository/NoSqlDataModule.cs +++ b/Repositories/Resgrid.Repositories.NoSqlRepository/NoSqlDataModule.cs @@ -14,6 +14,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); } } } diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsDocRepository.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsDocRepository.cs index 444c7be0e..7cc4dc80c 100644 --- a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsDocRepository.cs +++ b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsDocRepository.cs @@ -103,7 +103,7 @@ public async Task GetByOldIdAsync(string id) } } - public async Task InsertAsync(UnitsLocation location) + public async Task InsertAsync(UnitsLocation location) { if (location == null) throw new ArgumentNullException(nameof(location)); @@ -114,20 +114,35 @@ public async Task InsertAsync(UnitsLocation location) { await connection.OpenAsync(); var result = await connection.ExecuteScalarAsync( - "INSERT INTO public.unitlocations (departmentid, unitid, data) VALUES (@departmentId, @unitId, CAST(@dataJson AS jsonb)) RETURNING id::text;", + @"INSERT INTO public.unitlocations + (departmentid, unitid, ""timestamp"", eventid, receivedon, sourcetype, sourceid, sourcepriority, data) + VALUES + (@departmentId, @unitId, @timestamp, @eventId, @receivedOn, @sourceType, @sourceId, @sourcePriority, CAST(@dataJson AS jsonb)) + ON CONFLICT (eventid) WHERE eventid IS NOT NULL DO NOTHING + RETURNING id::text;", new { departmentId = location.DepartmentId, unitId = location.UnitId, + timestamp = ToPostgresTimestamp(location.Timestamp), + eventId = NullIfWhiteSpace(location.EventId), + receivedOn = location.ReceivedOn.HasValue ? ToPostgresTimestamp(location.ReceivedOn.Value) : (DateTime?)null, + sourceType = location.SourceType, + sourceId = NullIfWhiteSpace(location.SourceId), + sourcePriority = location.SourcePriority, dataJson }); + + if (string.IsNullOrWhiteSpace(result)) + return UnitLocationWriteResult.Duplicate(location); + location.PgId = result; - return location; + return UnitLocationWriteResult.Inserted(location); } } - public async Task UpdateAsync(UnitsLocation location) + public async Task UpdateAsync(UnitsLocation location) { if (location == null) throw new ArgumentNullException(nameof(location)); @@ -144,18 +159,47 @@ public async Task UpdateAsync(UnitsLocation location) { await connection.OpenAsync(); - await connection.ExecuteAsync( - "UPDATE public.unitlocations SET departmentid = @departmentId, unitid = @unitId, data = CAST(@dataJson AS jsonb) WHERE id = @id;", + var affectedRows = await connection.ExecuteAsync( + @"UPDATE public.unitlocations + SET departmentid = @departmentId, + unitid = @unitId, + ""timestamp"" = @timestamp, + eventid = @eventId, + receivedon = @receivedOn, + sourcetype = @sourceType, + sourceid = @sourceId, + sourcepriority = @sourcePriority, + data = CAST(@dataJson AS jsonb) + WHERE id = @id;", new { departmentId = location.DepartmentId, unitId = location.UnitId, + timestamp = ToPostgresTimestamp(location.Timestamp), + eventId = NullIfWhiteSpace(location.EventId), + receivedOn = location.ReceivedOn.HasValue ? ToPostgresTimestamp(location.ReceivedOn.Value) : (DateTime?)null, + sourceType = location.SourceType, + sourceId = NullIfWhiteSpace(location.SourceId), + sourcePriority = location.SourcePriority, dataJson, id = pgId }); - return location; + if (affectedRows != 1) + throw new InvalidOperationException($"Unit location '{location.PgId}' was not found for update."); + + return UnitLocationWriteResult.Inserted(location); } } + + private static DateTime ToPostgresTimestamp(DateTime value) + { + return DateTime.SpecifyKind(value, DateTimeKind.Unspecified); + } + + private static string NullIfWhiteSpace(string value) + { + return string.IsNullOrWhiteSpace(value) ? null : value; + } } } diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs new file mode 100644 index 000000000..af6990161 --- /dev/null +++ b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using MongoDB.Driver; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; + +namespace Resgrid.Repositories.NoSqlRepository +{ + public class UnitLocationsMongoRepository : IUnitLocationsMongoRepository + { + private readonly IMongoCollection _collection; + private readonly object _indexLock = new object(); + private Task _ensureIndexesTask; + + public UnitLocationsMongoRepository() + { + var database = new MongoClient(DataConfig.NoSqlConnectionString).GetDatabase(DataConfig.NoSqlDatabaseName); + _collection = database.GetCollection("unitLocations"); + } + + public async Task InsertAsync(UnitsLocation location) + { + if (location == null) + throw new ArgumentNullException(nameof(location)); + + await EnsureIndexesAsync(); + + try + { + await _collection.InsertOneAsync(location); + return UnitLocationWriteResult.Inserted(location); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return UnitLocationWriteResult.Duplicate(location); + } + } + + public async Task UpdateAsync(UnitsLocation location) + { + if (location == null) + throw new ArgumentNullException(nameof(location)); + + await EnsureIndexesAsync(); + + var filter = Builders.Filter.Eq(document => document.Id, location.Id); + var result = await _collection.ReplaceOneAsync(filter, location); + + if (result.MatchedCount != 1) + throw new InvalidOperationException($"Unit location '{location.Id}' was not found for update."); + + return UnitLocationWriteResult.Inserted(location); + } + + public Task EnsureIndexesAsync() + { + lock (_indexLock) + { + return _ensureIndexesTask ??= CreateIndexesAsync(); + } + } + + private async Task CreateIndexesAsync() + { + var indexes = new List> + { + new CreateIndexModel( + Builders.IndexKeys.Ascending(location => location.EventId), + new CreateIndexOptions { Name = "ux_unitlocations_eventid", Unique = true, Sparse = true }), + new CreateIndexModel( + Builders.IndexKeys + .Ascending(location => location.DepartmentId) + .Ascending(location => location.UnitId) + .Descending(location => location.Timestamp), + new CreateIndexOptions { Name = "ix_unitlocations_department_unit_timestamp" }), + new CreateIndexModel( + Builders.IndexKeys + .Ascending(location => location.DepartmentId) + .Ascending(location => location.UnitId) + .Ascending(location => location.SourceType) + .Ascending(location => location.SourceId) + .Descending(location => location.Timestamp), + new CreateIndexOptions { Name = "ix_unitlocations_department_unit_source_timestamp" }), + new CreateIndexModel( + Builders.IndexKeys + .Ascending(location => location.DepartmentId) + .Ascending(location => location.SourceType) + .Ascending(location => location.Timestamp), + new CreateIndexOptions { Name = "ix_unitlocations_retention" }) + }; + + await _collection.Indexes.CreateManyAsync(indexes); + } + } +} diff --git a/Tests/Resgrid.Tests/Framework/SentryTransactionFilterTests.cs b/Tests/Resgrid.Tests/Framework/SentryTransactionFilterTests.cs index 026e67e9e..71fa71ea3 100644 --- a/Tests/Resgrid.Tests/Framework/SentryTransactionFilterTests.cs +++ b/Tests/Resgrid.Tests/Framework/SentryTransactionFilterTests.cs @@ -82,5 +82,17 @@ public void ShouldDrop_Resgrid404_ReturnsFalse() // Assert result.Should().BeFalse(); } + + [Test] + public void RedactCapabilityPath_RemovesTokenFromTraceValues() + { + const string token = "rgtrk_prefix12_super-secret-capability"; + + var result = SentryTransactionFilter.RedactCapabilityPath( + $"POST /api/v4/unit-trackers/c/{token}"); + + result.Should().Be("POST /api/v4/unit-trackers/c/[REDACTED]"); + result.Should().NotContain(token); + } } } diff --git a/Tests/Resgrid.Tests/Framework/UnitLocationEventSerializationTests.cs b/Tests/Resgrid.Tests/Framework/UnitLocationEventSerializationTests.cs new file mode 100644 index 000000000..55bb2726d --- /dev/null +++ b/Tests/Resgrid.Tests/Framework/UnitLocationEventSerializationTests.cs @@ -0,0 +1,125 @@ +using System; +using System.IO; +using FluentAssertions; +using NUnit.Framework; +using ProtoBuf; +using Resgrid.Framework; +using Resgrid.Model.Events; + +namespace Resgrid.Tests.Framework +{ + [TestFixture] + public class UnitLocationEventSerializationTests + { + [Test] + public void Deserialize_should_preserve_legacy_members_and_default_new_members() + { + var timestamp = new DateTime(2026, 7, 24, 12, 30, 0, DateTimeKind.Utc); + var legacyEvent = new LegacyUnitLocationEvent + { + EventId = "legacy-event", + DepartmentId = 7, + UnitId = 12, + Timestamp = timestamp, + Latitude = 39.7392m, + Longitude = -104.9903m, + Speed = 15.5m, + Heading = 270m + }; + using var stream = new MemoryStream(); + Serializer.Serialize(stream, legacyEvent); + stream.Position = 0; + + var result = Serializer.Deserialize(stream); + + result.EventId.Should().Be("legacy-event"); + result.DepartmentId.Should().Be(7); + result.UnitId.Should().Be(12); + result.Timestamp.Should().Be(timestamp); + result.Latitude.Should().Be(39.7392m); + result.Longitude.Should().Be(-104.9903m); + result.Speed.Should().Be(15.5m); + result.Heading.Should().Be(270m); + result.ReceivedOn.Should().BeNull(); + result.IsValidFix.Should().BeNull(); + result.SourceType.Should().Be(0); + result.SourcePriority.Should().Be(0); + } + + [Test] + public void Serialize_should_round_trip_tracking_members() + { + var receivedOn = new DateTime(2026, 7, 24, 12, 30, 5, DateTimeKind.Utc); + var unitLocationEvent = new UnitLocationEvent + { + EventId = "tracking-event", + DepartmentId = 7, + UnitId = 12, + Timestamp = receivedOn.AddSeconds(-5), + ReceivedOn = receivedOn, + SourceType = 2, + SourceId = "binding-1", + SourcePriority = 100, + TransportType = 3, + ProtocolKey = "teltonika-codec8", + IsValidFix = true, + Latitude = 39.7392m, + Longitude = -104.9903m, + Satellites = 12, + Hdop = 0.8m, + BatteryPercent = 88m, + ExternalPowerVolts = 13.6m, + SignalPercent = 75, + Ignition = true, + IsMoving = true, + AlarmCode = "sos", + TimestampSource = 1 + }; + + var serialized = ObjectSerialization.Serialize(unitLocationEvent); + var result = ObjectSerialization.Deserialize(serialized); + + result.Should().BeEquivalentTo(unitLocationEvent); + } + + [ProtoContract] + public class LegacyUnitLocationEvent + { + [ProtoMember(1)] + public string EventId { get; set; } + + [ProtoMember(2)] + public int UnitLocationId { get; set; } + + [ProtoMember(3)] + public int DepartmentId { get; set; } + + [ProtoMember(4)] + public int UnitId { get; set; } + + [ProtoMember(5)] + public DateTime Timestamp { get; set; } + + [ProtoMember(6)] + public decimal? Latitude { get; set; } + + [ProtoMember(7)] + public decimal? Longitude { get; set; } + + [ProtoMember(8)] + public decimal? Accuracy { get; set; } + + [ProtoMember(9)] + public decimal? Altitude { get; set; } + + [ProtoMember(10)] + public decimal? AltitudeAccuracy { get; set; } + + [ProtoMember(11)] + public decimal? Speed { get; set; } + + [ProtoMember(12)] + public decimal? Heading { get; set; } + } + } +} diff --git a/Tests/Resgrid.Tests/Providers/EventAggregatorTests.cs b/Tests/Resgrid.Tests/Providers/EventAggregatorTests.cs new file mode 100644 index 000000000..27c88da7c --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/EventAggregatorTests.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Providers.Bus; + +namespace Resgrid.Tests.Providers +{ + [TestFixture] + public class EventAggregatorTests + { + [Test] + public async Task SendMessageAsync_should_await_async_listener() + { + var aggregator = new EventAggregator(); + var listenerCompleted = false; + aggregator.AddAsyncListener(async message => + { + await Task.Yield(); + listenerCompleted = message == "location-updated"; + }); + + await aggregator.SendMessageAsync("location-updated"); + + listenerCompleted.Should().BeTrue(); + } + + [Test] + public async Task SendMessageAsync_should_propagate_listener_failure() + { + var aggregator = new EventAggregator(); + aggregator.AddAsyncListener(_ => + Task.FromException(new InvalidOperationException("Realtime publish failed."))); + Func act = async () => await aggregator.SendMessageAsync("location-updated"); + + await act.Should().ThrowAsync() + .WithMessage("Realtime publish failed."); + } + } +} diff --git a/Tests/Resgrid.Tests/Providers/UnitLocationEventProviderTests.cs b/Tests/Resgrid.Tests/Providers/UnitLocationEventProviderTests.cs new file mode 100644 index 000000000..78fbe0cb2 --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/UnitLocationEventProviderTests.cs @@ -0,0 +1,63 @@ +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Providers.Bus; + +namespace Resgrid.Tests.Providers +{ + [TestFixture] + public class UnitLocationEventProviderTests + { + [TestCase(true)] + [TestCase(false)] + public async Task EnqueueUnitLocationEventAsync_should_return_the_confirmed_publish_result(bool publishResult) + { + var unitLocationEvent = new UnitLocationEvent { DepartmentId = 7, UnitId = 12 }; + var queueProvider = new Mock(); + queueProvider + .Setup(provider => provider.EnqueueUnitLocationEvent(unitLocationEvent)) + .ReturnsAsync(publishResult); + var provider = new UnitLocationEventProvider(queueProvider.Object); + + var result = await provider.EnqueueUnitLocationEventAsync(unitLocationEvent); + + result.Should().Be(publishResult); + queueProvider.Verify( + outbound => outbound.EnqueueUnitLocationEvent(unitLocationEvent), + Times.Once); + } + + [TestCase(true)] + [TestCase(false)] + public async Task EnqueueUnitLocationEventsAsync_should_return_the_confirmed_batch_result( + bool publishResult) + { + var events = new List + { + new() { DepartmentId = 7, UnitId = 12 }, + new() { DepartmentId = 7, UnitId = 12 } + }; + var queueProvider = new Mock(); + queueProvider + .Setup(provider => provider.EnqueueUnitLocationEvents( + events, + It.IsAny())) + .ReturnsAsync(publishResult); + var provider = new UnitLocationEventProvider(queueProvider.Object); + + var result = await provider.EnqueueUnitLocationEventsAsync(events); + + result.Should().Be(publishResult); + queueProvider.Verify( + outbound => outbound.EnqueueUnitLocationEvents( + events, + It.IsAny()), + Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Repositories/DocumentDbRepositoryTests.cs b/Tests/Resgrid.Tests/Repositories/DocumentDbRepositoryTests.cs new file mode 100644 index 000000000..76096f56b --- /dev/null +++ b/Tests/Resgrid.Tests/Repositories/DocumentDbRepositoryTests.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Repositories.NoSqlRepository; + +namespace Resgrid.Tests.Repositories +{ + [TestFixture] + public class DocumentDbRepositoryTests + { + private DatabaseTypes _originalDocDatabaseType; + + [SetUp] + public void SetUp() + { + _originalDocDatabaseType = DataConfig.DocDatabaseType; + } + + [TearDown] + public void TearDown() + { + DataConfig.DocDatabaseType = _originalDocDatabaseType; + } + + [Test] + public async Task UpdateDocumentDatabaseAsync_should_ensure_unit_location_indexes_for_mongodb() + { + DataConfig.DocDatabaseType = DatabaseTypes.MongoDb; + var mongoRepository = new Mock(); + mongoRepository + .Setup(repository => repository.EnsureIndexesAsync()) + .Returns(Task.CompletedTask); + var repository = new DocumentDbRepository( + new Lazy(() => mongoRepository.Object)); + + var result = await repository.UpdateDocumentDatabaseAsync(); + + result.Should().BeTrue(); + mongoRepository.Verify(locationRepository => locationRepository.EnsureIndexesAsync(), Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Resgrid.Tests.csproj b/Tests/Resgrid.Tests/Resgrid.Tests.csproj index 77e049a27..b96d897dd 100644 --- a/Tests/Resgrid.Tests/Resgrid.Tests.csproj +++ b/Tests/Resgrid.Tests/Resgrid.Tests.csproj @@ -38,6 +38,7 @@ + @@ -54,6 +55,7 @@ + diff --git a/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceUnitTrackingTests.cs b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceUnitTrackingTests.cs new file mode 100644 index 000000000..246da55c5 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceUnitTrackingTests.cs @@ -0,0 +1,95 @@ +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + [NonParallelizable] + public class DepartmentSettingsServiceUnitTrackingTests + { + private Mock _repository; + private Mock _cacheProvider; + private DepartmentSettingsService _service; + private bool _originalCacheEnabled; + + [SetUp] + public void SetUp() + { + _originalCacheEnabled = SystemBehaviorConfig.CacheEnabled; + SystemBehaviorConfig.CacheEnabled = false; + _repository = new Mock(); + _cacheProvider = new Mock(); + _service = new DepartmentSettingsService( + _repository.Object, + Mock.Of(), + Mock.Of(), + _cacheProvider.Object); + } + + [TearDown] + public void TearDown() + { + SystemBehaviorConfig.CacheEnabled = _originalCacheEnabled; + } + + [Test] + public async Task TrackingSettings_MissingValues_ReturnDesignDefaults() + { + var staleSeconds = await _service.GetHardwareTrackingStaleAfterSecondsAsync(7); + var fallbackEnabled = await _service.GetHardwareTrackingMobileFallbackEnabledAsync(7); + var retentionDays = await _service.GetHardwareTrackingLocationRetentionDaysAsync(7); + + staleSeconds.Should().Be(180); + fallbackEnabled.Should().BeTrue(); + retentionDays.Should().Be(UnitTrackingConfig.DefaultLocationRetentionDays); + } + + [Test] + public async Task GetHardwareTrackingLocationRetentionDaysAsync_OutOfRangeValue_IsClamped() + { + _repository + .Setup(repository => repository.GetDepartmentSettingByIdTypeAsync( + 7, + DepartmentSettingTypes.HardwareTrackingLocationRetentionDays)) + .ReturnsAsync(new DepartmentSetting + { + DepartmentId = 7, + SettingType = (int)DepartmentSettingTypes.HardwareTrackingLocationRetentionDays, + Setting = "99999" + }); + + var retentionDays = await _service.GetHardwareTrackingLocationRetentionDaysAsync(7); + + retentionDays.Should().Be(UnitTrackingConfig.MaximumLocationRetentionDays); + } + + [Test] + public async Task SaveOrUpdateSettingAsync_FirstTrackingWrite_InvalidatesFallbackCache() + { + _repository + .Setup(repository => repository.SaveOrUpdateAsync( + It.IsAny(), + It.IsAny(), + false)) + .ReturnsAsync((DepartmentSetting setting, CancellationToken cancellationToken, bool firstLevelOnly) => setting); + + await _service.SaveOrUpdateSettingAsync( + 7, + "240", + DepartmentSettingTypes.HardwareTrackingStaleAfterSeconds); + + _cacheProvider.Verify( + provider => provider.RemoveAsync("DSetHardwareTrackingStale_7"), + Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs b/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs index b5218ef16..378dc707b 100644 --- a/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs +++ b/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs @@ -62,13 +62,16 @@ public async Task AddUnitLocationAsync_should_publish_postgres_record_id_when_do DataConfig.DocDatabaseType = DatabaseTypes.Postgres; var eventAggregator = new Mock(); + eventAggregator + .Setup(x => x.SendMessageAsync(It.IsAny())) + .Returns(Task.CompletedTask); var unitLocationsDocRepository = new Mock(); unitLocationsDocRepository .Setup(x => x.InsertAsync(It.IsAny())) .ReturnsAsync((UnitsLocation location) => { location.PgId = "314"; - return location; + return UnitLocationWriteResult.Inserted(location); }); var service = CreateUnitsService( @@ -87,13 +90,77 @@ public async Task AddUnitLocationAsync_should_publish_postgres_record_id_when_do var result = await service.AddUnitLocationAsync(location, 7); - result.PgId.Should().Be("314"); + result.Status.Should().Be(UnitLocationWriteStatus.Inserted); + result.Location.PgId.Should().Be("314"); unitLocationsDocRepository.Verify(x => x.InsertAsync(location), Times.Once); eventAggregator.Verify( - x => x.SendMessage(It.Is(e => e.RecordId == "314" && e.UnitId == "12")), + x => x.SendMessageAsync(It.Is(e => e.RecordId == "314" && e.UnitId == "12")), Times.Once); } + [Test] + public async Task AddUnitLocationAsync_should_not_publish_realtime_event_for_duplicate() + { + DataConfig.DocDatabaseType = DatabaseTypes.Postgres; + + var eventAggregator = new Mock(); + var unitLocationsDocRepository = new Mock(); + unitLocationsDocRepository + .Setup(x => x.InsertAsync(It.IsAny())) + .ReturnsAsync((UnitsLocation location) => UnitLocationWriteResult.Duplicate(location)); + + var service = CreateUnitsService( + eventAggregator.Object, + new Lazy>(() => throw new InvalidOperationException("Mongo repository should not be resolved in Postgres mode.")), + unitLocationsDocRepository.Object); + var location = new UnitsLocation + { + EventId = "duplicate-event", + DepartmentId = 7, + UnitId = 12, + Latitude = 39.7392m, + Longitude = -104.9903m, + Timestamp = DateTime.UtcNow + }; + + var result = await service.AddUnitLocationAsync(location, 7); + + result.Status.Should().Be(UnitLocationWriteStatus.Duplicate); + eventAggregator.Verify( + x => x.SendMessageAsync(It.IsAny()), + Times.Never); + } + + [Test] + public async Task AddUnitLocationAsync_should_propagate_storage_failure() + { + DataConfig.DocDatabaseType = DatabaseTypes.Postgres; + + var unitLocationsDocRepository = new Mock(); + unitLocationsDocRepository + .Setup(x => x.InsertAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Document database unavailable.")); + + var service = CreateUnitsService( + new Mock().Object, + new Lazy>(() => throw new InvalidOperationException("Mongo repository should not be resolved in Postgres mode.")), + unitLocationsDocRepository.Object); + var location = new UnitsLocation + { + EventId = "failed-event", + DepartmentId = 7, + UnitId = 12, + Latitude = 39.7392m, + Longitude = -104.9903m, + Timestamp = DateTime.UtcNow + }; + + Func act = async () => await service.AddUnitLocationAsync(location, 7); + + await act.Should().ThrowAsync() + .WithMessage("Document database unavailable."); + } + [Test] public async Task GetLatestLocationsForDepartmentPersonnelAsync_should_use_postgres_doc_repository_when_doc_database_is_postgres() { @@ -196,6 +263,7 @@ private static UnitsService CreateUnitsService(IEventAggregator eventAggregator, new Mock().Object, mongoRepository, unitLocationsDocRepository, + new Lazy(() => new Mock().Object), new Mock().Object, new Mock().Object, new Mock().Object, diff --git a/Tests/Resgrid.Tests/Services/UnitLocationSourceResolverTests.cs b/Tests/Resgrid.Tests/Services/UnitLocationSourceResolverTests.cs new file mode 100644 index 000000000..614738577 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/UnitLocationSourceResolverTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class UnitLocationSourceResolverTests + { + private const int DepartmentId = 10; + private Mock _settingsService; + private UnitLocationSourceResolver _resolver; + private DateTime _now; + + [SetUp] + public void SetUp() + { + _now = new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc); + _settingsService = new Mock(); + _settingsService + .Setup(service => service.GetHardwareTrackingStaleAfterSecondsAsync(DepartmentId, false)) + .ReturnsAsync(180); + _settingsService + .Setup(service => service.GetHardwareTrackingMobileFallbackEnabledAsync(DepartmentId, false)) + .ReturnsAsync(true); + _resolver = new UnitLocationSourceResolver(_settingsService.Object); + } + + [Test] + public async Task ResolveAsync_FreshHardwareAndMobile_SelectsHigherPriorityHardware() + { + var hardware = Location( + "hardware", + UnitLocationSourceType.HardwareTracker, + 100, + _now.AddSeconds(-60), + _now.AddSeconds(-30)); + var mobile = Location( + "mobile", + UnitLocationSourceType.UnitApp, + 0, + _now.AddSeconds(-5), + _now.AddSeconds(-5)); + + var result = await _resolver.ResolveAsync( + DepartmentId, + new List { mobile, hardware }, + _now); + + result.Location.Should().BeSameAs(hardware); + result.IsStale.Should().BeFalse(); + } + + [Test] + public async Task ResolveAsync_StaleHardwareAndFreshMobileWithFallbackDisabled_ReturnsNull() + { + _settingsService + .Setup(service => service.GetHardwareTrackingMobileFallbackEnabledAsync(DepartmentId, false)) + .ReturnsAsync(false); + var hardware = Location( + "hardware", + UnitLocationSourceType.HardwareTracker, + 100, + _now.AddMinutes(-10), + _now.AddMinutes(-10)); + var mobile = Location( + "mobile", + UnitLocationSourceType.UnitApp, + 0, + _now.AddSeconds(-5), + _now.AddSeconds(-5)); + + var result = await _resolver.ResolveAsync( + DepartmentId, + new List { mobile, hardware }, + _now); + + result.Should().BeNull(); + } + + [Test] + public async Task ResolveAsync_NoFreshSourcesAndFallbackEnabled_ReturnsNewestPointAsStale() + { + var older = Location( + "hardware", + UnitLocationSourceType.HardwareTracker, + 100, + _now.AddMinutes(-20), + _now.AddMinutes(-19)); + var newer = Location( + "mobile", + UnitLocationSourceType.UnitApp, + 0, + _now.AddMinutes(-10), + _now.AddMinutes(-9)); + + var result = await _resolver.ResolveAsync( + DepartmentId, + new List { older, newer }, + _now); + + result.Location.Should().BeSameAs(newer); + result.IsStale.Should().BeTrue(); + } + + private static UnitsLocation Location( + string sourceId, + UnitLocationSourceType sourceType, + int priority, + DateTime timestamp, + DateTime receivedOn) + { + return new UnitsLocation + { + DepartmentId = DepartmentId, + UnitId = 42, + SourceId = sourceId, + SourceType = (int)sourceType, + SourcePriority = priority, + IsValidFix = true, + Timestamp = timestamp, + ReceivedOn = receivedOn, + Latitude = 47.6062m, + Longitude = -122.3321m + }; + } + } +} diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingAuthenticationServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingAuthenticationServiceTests.cs new file mode 100644 index 000000000..80bf01a23 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/UnitTrackingAuthenticationServiceTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + [NonParallelizable] + public class UnitTrackingAuthenticationServiceTests + { + private Mock _credentialsRepository; + private Mock _devicesRepository; + private UnitTrackingAuthenticationService _service; + private string _originalPepper; + private bool _originalCacheEnabled; + + [SetUp] + public void SetUp() + { + _originalPepper = UnitTrackingConfig.CredentialPepper; + _originalCacheEnabled = SystemBehaviorConfig.CacheEnabled; + UnitTrackingConfig.CredentialPepper = "unit-test-pepper-with-enough-entropy"; + SystemBehaviorConfig.CacheEnabled = false; + + _credentialsRepository = new Mock(); + _devicesRepository = new Mock(); + _service = new UnitTrackingAuthenticationService( + _credentialsRepository.Object, + _devicesRepository.Object, + new UnitTrackingIdentifierService(), + Mock.Of()); + } + + [TearDown] + public void TearDown() + { + UnitTrackingConfig.CredentialPepper = _originalPepper; + SystemBehaviorConfig.CacheEnabled = _originalCacheEnabled; + } + + [Test] + public void GenerateCredential_CreatesOneTimeTokenAndLowercaseHash() + { + var generated = _service.GenerateCredential(); + + generated.Token.Should().StartWith($"rgtrk_{generated.KeyPrefix}_"); + generated.KeyPrefix.Should().HaveLength(8); + generated.SecretHash.Should().MatchRegex("^[0-9a-f]{64}$"); + _service.VerifySecret(generated.Token, generated.SecretHash).Should().BeTrue(); + _service.VerifySecret(generated.Token + "x", generated.SecretHash).Should().BeFalse(); + } + + [Test] + public async Task AuthenticateAsync_ActiveCredentialAndDevice_ReturnsBinding() + { + var now = new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc); + var generated = _service.GenerateCredential(); + var credential = new UnitTrackingCredential + { + UnitTrackingCredentialId = "credential-1", + UnitTrackingDeviceId = "device-1", + SecretHash = generated.SecretHash, + ValidFrom = now.AddMinutes(-1) + }; + var device = new UnitTrackingDevice + { + UnitTrackingDeviceId = "device-1", + IsEnabled = true + }; + _credentialsRepository + .Setup(repository => repository.GetBySecretHashAsync(generated.SecretHash)) + .ReturnsAsync(credential); + _devicesRepository + .Setup(repository => repository.GetByIdAsync("device-1")) + .ReturnsAsync(device); + + var result = await _service.AuthenticateAsync(generated.Token, now); + + result.Should().NotBeNull(); + result.Device.Should().BeSameAs(device); + result.Credential.Should().BeSameAs(credential); + } + + [TestCase(true, false)] + [TestCase(false, true)] + public async Task AuthenticateAsync_InactiveCredential_ReturnsNull(bool revoked, bool expired) + { + var now = new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc); + var generated = _service.GenerateCredential(); + _credentialsRepository + .Setup(repository => repository.GetBySecretHashAsync(generated.SecretHash)) + .ReturnsAsync(new UnitTrackingCredential + { + UnitTrackingDeviceId = "device-1", + SecretHash = generated.SecretHash, + ValidFrom = now.AddDays(-1), + RevokedOn = revoked ? now.AddMinutes(-1) : null, + ExpiresOn = expired ? now.AddMinutes(-1) : null + }); + _devicesRepository + .Setup(repository => repository.GetByIdAsync("device-1")) + .ReturnsAsync(new UnitTrackingDevice + { + UnitTrackingDeviceId = "device-1", + IsEnabled = true + }); + + var result = await _service.AuthenticateAsync(generated.Token, now); + + result.Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs new file mode 100644 index 000000000..f028524bc --- /dev/null +++ b/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs @@ -0,0 +1,52 @@ +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class UnitTrackingCatalogServiceTests + { + [Test] + public async Task GetProfilesAsync_FoundationProfiles_ExposeCertificationAndAdapters() + { + // Arrange + var service = new UnitTrackingCatalogService(); + + // Act + var profiles = await service.GetProfilesAsync(); + + // Assert + profiles.Select(profile => profile.Key) + .Should().BeEquivalentTo("generic-https", "traccar-forwarder"); + profiles.Single(profile => profile.Key == "generic-https") + .CertificationStatus.Should().Be(UnitTrackingCertificationStatus.Certified); + profiles.Single(profile => profile.Key == "traccar-forwarder") + .CertificationStatus.Should().Be(UnitTrackingCertificationStatus.Candidate); + profiles.Single(profile => profile.Key == "generic-https") + .IsSelectable.Should().BeTrue(); + profiles.Single(profile => profile.Key == "traccar-forwarder") + .IsSelectable.Should().BeFalse(); + profiles.Should().OnlyContain(profile => + !string.IsNullOrWhiteSpace(profile.PayloadAdapterKey) && + profile.SupportedAuthModes.Count > 0); + } + + [Test] + public async Task GetProfileAsync_KeyComparison_IsCaseInsensitive() + { + // Arrange + var service = new UnitTrackingCatalogService(); + + // Act + var profile = await service.GetProfileAsync(" GENERIC-HTTPS "); + + // Assert + profile.Should().NotBeNull(); + profile.Key.Should().Be("generic-https"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingEventIdServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingEventIdServiceTests.cs new file mode 100644 index 000000000..00af4c27b --- /dev/null +++ b/Tests/Resgrid.Tests/Services/UnitTrackingEventIdServiceTests.cs @@ -0,0 +1,24 @@ +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class UnitTrackingEventIdServiceTests + { + [Test] + public void CreateForHttps_SameBindingAndCallerId_IsStableAndBounded() + { + var service = new UnitTrackingEventIdService(); + + var first = service.CreateForHttps("binding-1", "vendor-record-42"); + var retry = service.CreateForHttps("binding-1", "vendor-record-42"); + var otherBinding = service.CreateForHttps("binding-2", "vendor-record-42"); + + first.Should().Be(retry); + first.Should().MatchRegex("^[0-9a-f]{64}$"); + otherBinding.Should().NotBe(first); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingIdentifierServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingIdentifierServiceTests.cs new file mode 100644 index 000000000..3911c6488 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/UnitTrackingIdentifierServiceTests.cs @@ -0,0 +1,43 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class UnitTrackingIdentifierServiceTests + { + private UnitTrackingIdentifierService _service; + + [SetUp] + public void SetUp() + { + _service = new UnitTrackingIdentifierService(); + } + + [Test] + public void Normalize_WhitespaceAndCase_ReturnsStableIdentifier() + { + var result = _service.Normalize(" imei-Abc123 "); + + result.Should().Be("IMEI-ABC123"); + } + + [Test] + public void Mask_Identifier_RevealsOnlyLastFourCharacters() + { + var result = _service.Mask("123456789012345"); + + result.Should().Be("***********2345"); + } + + [Test] + public void Normalize_IdentifierOverMaximumLength_Throws() + { + Action act = () => _service.Normalize(new string('a', 129)); + + act.Should().Throw(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingIngressServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingIngressServiceTests.cs new file mode 100644 index 000000000..56fd00ec9 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/UnitTrackingIngressServiceTests.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.Tracking; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + [NonParallelizable] + public class UnitTrackingIngressServiceTests + { + private const int DepartmentId = 10; + private const int UnitId = 42; + private readonly DateTime _receivedOn = + new(2026, 7, 24, 18, 42, 52, DateTimeKind.Utc); + + private Mock _eventProvider; + private Mock _devicesRepository; + private Mock _settingsService; + private Mock _unitsService; + private UnitTrackingIngressService _service; + private int _originalMaxFutureSkewSeconds; + + [SetUp] + public void SetUp() + { + _originalMaxFutureSkewSeconds = UnitTrackingConfig.MaxFutureSkewSeconds; + UnitTrackingConfig.MaxFutureSkewSeconds = 300; + + _eventProvider = new Mock(); + _devicesRepository = new Mock(); + _settingsService = new Mock(); + _unitsService = new Mock(); + + _eventProvider + .Setup(provider => provider.EnqueueUnitLocationEventsAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(true); + _devicesRepository + .Setup(repository => repository.UpdateAsync( + It.IsAny(), + It.IsAny(), + false)) + .ReturnsAsync((UnitTrackingDevice device, CancellationToken cancellationToken, bool firstLevelOnly) => + device); + _settingsService + .Setup(service => service.GetHardwareTrackingLocationRetentionDaysAsync(DepartmentId, false)) + .ReturnsAsync(90); + _unitsService + .Setup(service => service.GetUnitByIdAsync(UnitId)) + .ReturnsAsync(new Unit { UnitId = UnitId, DepartmentId = DepartmentId }); + + _service = new UnitTrackingIngressService( + _eventProvider.Object, + _devicesRepository.Object, + new UnitTrackingEventIdService(), + new UnitTrackingIdentifierService(), + _settingsService.Object, + _unitsService.Object); + } + + [TearDown] + public void TearDown() + { + UnitTrackingConfig.MaxFutureSkewSeconds = _originalMaxFutureSkewSeconds; + } + + [Test] + public async Task AcceptAsync_ValidBatch_NormalizesAndPublishesAllWithBindingMetadata() + { + IReadOnlyCollection published = null; + _eventProvider + .Setup(provider => provider.EnqueueUnitLocationEventsAsync( + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (events, cancellationToken) => published = events) + .ReturnsAsync(true); + var positions = new[] + { + Position("record-1", _receivedOn.AddSeconds(-2), 361m), + Position("record-2", _receivedOn.AddSeconds(-1), -1m) + }; + positions[0].AccuracyMeters = -1m; + + var result = await _service.AcceptAsync(Source(), positions); + + result.Status.Should().Be(TrackingIngressStatus.Accepted); + result.Accepted.Should().Be(2); + published.Should().HaveCount(2); + published.Should().OnlyContain(item => + item.DepartmentId == DepartmentId && + item.UnitId == UnitId && + item.SourceType == (int)UnitLocationSourceType.HardwareTracker && + item.SourceId == "device-1" && + item.SourcePriority == 100); + published.ElementAt(0).Heading.Should().Be(1m); + published.ElementAt(0).Accuracy.Should().BeNull(); + published.ElementAt(1).Heading.Should().Be(359m); + published.Select(item => item.EventId).Should().OnlyContain(id => id.Length == 64); + } + + [Test] + public async Task AcceptAsync_OneFutureRecord_RejectsWholeBatchBeforePublishing() + { + var positions = new[] + { + Position("valid", _receivedOn, 0m), + Position("future", _receivedOn.AddMinutes(6), 0m) + }; + + var result = await _service.AcceptAsync(Source(), positions); + + result.Status.Should().Be(TrackingIngressStatus.Invalid); + result.Errors.Should().Contain(error => error.Contains("positions[1]")); + _eventProvider.Verify(provider => provider.EnqueueUnitLocationEventsAsync( + It.IsAny>(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task AcceptAsync_CallerEventIdOverMaximum_RejectsBeforePublishing() + { + var position = Position(new string('e', 257), _receivedOn, 0m); + + var result = await _service.AcceptAsync(Source(), new[] { position }); + + result.Status.Should().Be(TrackingIngressStatus.Invalid); + result.Errors.Should().Contain(error => error.Contains("eventId cannot exceed 256")); + _eventProvider.Verify(provider => provider.EnqueueUnitLocationEventsAsync( + It.IsAny>(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task AcceptAsync_MissingAndBufferedTimestamps_UseServerTimeAndRetentionWindow() + { + var missing = Position("missing", default, 0m); + missing.TimestampSource = TrackingTimestampSource.Unknown; + var buffered = Position("buffered", _receivedOn.AddDays(-89), 0m); + IReadOnlyCollection published = null; + _eventProvider + .Setup(provider => provider.EnqueueUnitLocationEventsAsync( + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (events, cancellationToken) => published = events) + .ReturnsAsync(true); + + var result = await _service.AcceptAsync(Source(), new[] { missing, buffered }); + + result.Status.Should().Be(TrackingIngressStatus.Accepted); + published.ElementAt(0).Timestamp.Should().Be(_receivedOn); + published.ElementAt(0).TimestampSource.Should().Be((int)TrackingTimestampSource.Server); + published.ElementAt(1).Timestamp.Should().Be(_receivedOn.AddDays(-89)); + } + + [Test] + public async Task AcceptAsync_TenantBindingMismatch_DoesNotPublish() + { + _unitsService + .Setup(service => service.GetUnitByIdAsync(UnitId)) + .ReturnsAsync(new Unit { UnitId = UnitId, DepartmentId = 999 }); + + var result = await _service.AcceptAsync(Source(), new[] { Position("record", _receivedOn, 0m) }); + + result.Status.Should().Be(TrackingIngressStatus.Invalid); + _eventProvider.Verify(provider => provider.EnqueueUnitLocationEventsAsync( + It.IsAny>(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task AcceptAsync_ReportedIdentifierMismatch_DoesNotPublish() + { + var source = Source(); + source.ReportedDeviceIdentifier = "OTHER-DEVICE"; + + var result = await _service.AcceptAsync(source, new[] { Position("record", _receivedOn, 0m) }); + + result.Status.Should().Be(TrackingIngressStatus.Invalid); + _eventProvider.Verify(provider => provider.EnqueueUnitLocationEventsAsync( + It.IsAny>(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task AcceptAsync_ConfirmedPublishFailure_ReturnsUnavailable() + { + _eventProvider + .Setup(provider => provider.EnqueueUnitLocationEventsAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(false); + + var result = await _service.AcceptAsync( + Source(), + new[] { Position("record", _receivedOn, 0m) }); + + result.Status.Should().Be(TrackingIngressStatus.Unavailable); + result.Accepted.Should().Be(0); + } + + private AuthenticatedTrackingSource Source() + { + return new AuthenticatedTrackingSource + { + Device = new UnitTrackingDevice + { + UnitTrackingDeviceId = "device-1", + DepartmentId = DepartmentId, + UnitId = UnitId, + DeviceIdentifier = "DEVICE-1234", + IsEnabled = true, + SourcePriority = 100, + TransportType = (int)UnitTrackingTransportType.NativeHttps, + ProtocolKey = "resgrid-json" + }, + Credential = new UnitTrackingCredential + { + UnitTrackingCredentialId = "credential-1" + } + }; + } + + private CanonicalTrackingPosition Position( + string eventId, + DateTime timestamp, + decimal heading) + { + return new CanonicalTrackingPosition + { + EventId = eventId, + TimestampUtc = timestamp, + ReceivedOnUtc = _receivedOn, + Latitude = 39.7392m, + Longitude = -104.9903m, + HeadingDegrees = heading, + TimestampSource = TrackingTimestampSource.Device, + IsValidFix = true + }; + } + } +} diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingServiceTests.cs new file mode 100644 index 000000000..800065622 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/UnitTrackingServiceTests.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + [NonParallelizable] + public class UnitTrackingServiceTests + { + private const int DepartmentId = 10; + private const int UnitId = 42; + private const string UserId = "admin-user"; + + private Mock _devicesRepository; + private Mock _credentialsRepository; + private Mock _authenticationService; + private Mock _unitsService; + private Mock _eventAggregator; + private Mock _unitOfWork; + private UnitTrackingService _service; + private List _auditEvents; + private string _originalPublicHttpsBaseUrl; + + [SetUp] + public void SetUp() + { + _originalPublicHttpsBaseUrl = UnitTrackingConfig.PublicHttpsBaseUrl; + UnitTrackingConfig.PublicHttpsBaseUrl = "https://tracking.example"; + + _devicesRepository = new Mock(); + _credentialsRepository = new Mock(); + _authenticationService = new Mock(); + _unitsService = new Mock(); + _eventAggregator = new Mock(); + _unitOfWork = new Mock(); + _auditEvents = new List(); + + _unitsService + .Setup(service => service.GetUnitByIdAsync(It.IsAny())) + .ReturnsAsync((int unitId) => new Unit + { + UnitId = unitId, + DepartmentId = DepartmentId, + Name = $"Unit {unitId}" + }); + _devicesRepository + .Setup(repository => repository.InsertAsync( + It.IsAny(), + It.IsAny(), + false)) + .ReturnsAsync((UnitTrackingDevice device, CancellationToken cancellationToken, bool firstLevelOnly) => device); + _devicesRepository + .Setup(repository => repository.UpdateAsync( + It.IsAny(), + It.IsAny(), + false)) + .ReturnsAsync((UnitTrackingDevice device, CancellationToken cancellationToken, bool firstLevelOnly) => device); + _credentialsRepository + .Setup(repository => repository.InsertAsync( + It.IsAny(), + It.IsAny(), + false)) + .ReturnsAsync((UnitTrackingCredential credential, CancellationToken cancellationToken, bool firstLevelOnly) => credential); + _credentialsRepository + .Setup(repository => repository.UpdateAsync( + It.IsAny(), + It.IsAny(), + false)) + .ReturnsAsync((UnitTrackingCredential credential, CancellationToken cancellationToken, bool firstLevelOnly) => credential); + _authenticationService + .Setup(service => service.InvalidateDeviceAsync(It.IsAny())) + .Returns(Task.CompletedTask); + _authenticationService + .Setup(service => service.InvalidateCredentialAsync(It.IsAny())) + .Returns(Task.CompletedTask); + _eventAggregator + .Setup(aggregator => aggregator.SendMessage(It.IsAny())) + .Callback(auditEvent => _auditEvents.Add(auditEvent)); + + _service = new UnitTrackingService( + _devicesRepository.Object, + _credentialsRepository.Object, + _authenticationService.Object, + new UnitTrackingIdentifierService(), + _unitsService.Object, + _eventAggregator.Object, + _unitOfWork.Object); + } + + [TearDown] + public void TearDown() + { + UnitTrackingConfig.PublicHttpsBaseUrl = _originalPublicHttpsBaseUrl; + } + + [Test] + public async Task CreateDeviceAsync_ValidBinding_NormalizesIdentifierAndPublishesRedactedAudit() + { + var result = await _service.CreateDeviceAsync( + new UnitTrackingDevice + { + UnitId = UnitId, + DisplayName = " Engine Tracker ", + TransportType = (int)UnitTrackingTransportType.NativeTcpUdp, + ProtocolKey = " GT06 ", + DeviceIdentifier = " secret-device-1234 ", + SourcePriority = 100 + }, + DepartmentId, + UserId); + + result.UnitTrackingDeviceId.Should().NotBeNullOrWhiteSpace(); + result.DepartmentId.Should().Be(DepartmentId); + result.ProtocolKey.Should().Be("gt06"); + result.DeviceIdentifier.Should().Be("SECRET-DEVICE-1234"); + result.CreatedByUserId.Should().Be(UserId); + _auditEvents.Should().ContainSingle(); + _auditEvents[0].Type.Should().Be(AuditLogTypes.UnitTrackingDeviceCreated); + _auditEvents[0].After.Should().NotContain("SECRET-DEVICE-1234"); + _auditEvents[0].After.Should().Contain("1234"); + } + + [Test] + public async Task CreateDeviceAsync_UnitOwnedByAnotherDepartment_RejectsBinding() + { + _unitsService + .Setup(service => service.GetUnitByIdAsync(UnitId)) + .ReturnsAsync(new Unit { UnitId = UnitId, DepartmentId = 999, Name = "Other Unit" }); + + Func act = () => _service.CreateDeviceAsync( + new UnitTrackingDevice + { + UnitId = UnitId, + TransportType = (int)UnitTrackingTransportType.NativeHttps + }, + DepartmentId, + UserId); + + await act.Should().ThrowAsync() + .WithMessage("*not found for this department*"); + _devicesRepository.Verify( + repository => repository.InsertAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Test] + public async Task CreateCredentialAsync_EnabledDevice_ReturnsTokenWithoutExposingStoredHash() + { + var device = Device(); + var generatedHash = new string('b', 64); + _devicesRepository + .Setup(repository => repository.GetByIdAsync(device.UnitTrackingDeviceId)) + .ReturnsAsync(device); + _authenticationService + .Setup(service => service.GenerateCredential()) + .Returns(new UnitTrackingGeneratedCredential + { + Token = "rgtrk_prefix12_generated-secret", + KeyPrefix = "prefix12", + SecretHash = generatedHash + }); + + var result = await _service.CreateCredentialAsync( + device.UnitTrackingDeviceId, + DepartmentId, + UnitTrackingAuthMode.Bearer, + UserId); + + result.Token.Should().Be("rgtrk_prefix12_generated-secret"); + result.Credential.SecretHash.Should().BeNull(); + result.EndpointUrl.Should().Be( + "https://tracking.example/api/v4/unit-trackers/device-1/positions"); + result.HeaderName.Should().Be("Authorization"); + result.HeaderValue.Should().Be("Bearer rgtrk_prefix12_generated-secret"); + _credentialsRepository.Verify( + repository => repository.InsertAsync( + It.Is(credential => + credential.SecretHash == generatedHash && + credential.KeyPrefix == "prefix12"), + It.IsAny(), + false), + Times.Once); + _auditEvents.Should().ContainSingle(audit => + audit.Type == AuditLogTypes.UnitTrackingCredentialCreated && + !audit.After.Contains(result.Token) && + !audit.After.Contains(generatedHash)); + } + + [Test] + public async Task CreateCredentialAsync_CapabilityPath_ReturnsOneTimeSecretOnlyInProvisioningData() + { + var device = Device(); + const string token = "rgtrk_capability_generated-secret"; + _devicesRepository + .Setup(repository => repository.GetByIdAsync(device.UnitTrackingDeviceId)) + .ReturnsAsync(device); + _authenticationService + .Setup(service => service.GenerateCredential()) + .Returns(new UnitTrackingGeneratedCredential + { + Token = token, + KeyPrefix = "capabili", + SecretHash = new string('c', 64) + }); + + var result = await _service.CreateCredentialAsync( + device.UnitTrackingDeviceId, + DepartmentId, + UnitTrackingAuthMode.CapabilityPath, + UserId); + + result.EndpointUrl.Should().Be( + $"https://tracking.example/api/v4/unit-trackers/c/{token}"); + result.HeaderName.Should().BeNull(); + result.HeaderValue.Should().BeNull(); + result.Credential.SecretHash.Should().BeNull(); + _auditEvents.Should().OnlyContain(audit => + !audit.After.Contains(token)); + } + + [Test] + public async Task RotateCredentialAsync_ValidCredential_ExpiresOldAndReturnsNewTokenOnce() + { + var device = Device(); + var existing = Credential("credential-old", "old-hash"); + _devicesRepository + .Setup(repository => repository.GetByIdAsync(device.UnitTrackingDeviceId)) + .ReturnsAsync(device); + _credentialsRepository + .Setup(repository => repository.GetByIdAsync(existing.UnitTrackingCredentialId)) + .ReturnsAsync(existing); + _authenticationService + .Setup(service => service.GenerateCredential()) + .Returns(new UnitTrackingGeneratedCredential + { + Token = "rgtrk_prefix12_generated-secret", + KeyPrefix = "prefix12", + SecretHash = new string('a', 64) + }); + + var before = DateTime.UtcNow; + var result = await _service.RotateCredentialAsync( + device.UnitTrackingDeviceId, + existing.UnitTrackingCredentialId, + DepartmentId, + UserId, + TimeSpan.FromHours(1)); + + existing.ExpiresOn.Should().NotBeNull(); + existing.ExpiresOn.Should().BeAfter(before.AddMinutes(59)); + result.Token.Should().Be("rgtrk_prefix12_generated-secret"); + result.Credential.SecretHash.Should().BeNull(); + _unitOfWork.Verify(unitOfWork => unitOfWork.CommitChanges(), Times.Once); + _auditEvents.Should().ContainSingle(audit => + audit.Type == AuditLogTypes.UnitTrackingCredentialRotated && + !audit.After.Contains(result.Token) && + !audit.After.Contains(new string('a', 64))); + } + + [Test] + public async Task RevokeCredentialAsync_ActiveCredential_RevokesAndInvalidates() + { + var device = Device(); + var credential = Credential("credential-1", "credential-hash"); + _devicesRepository + .Setup(repository => repository.GetByIdAsync(device.UnitTrackingDeviceId)) + .ReturnsAsync(device); + _credentialsRepository + .Setup(repository => repository.GetByIdAsync(credential.UnitTrackingCredentialId)) + .ReturnsAsync(credential); + + var result = await _service.RevokeCredentialAsync( + device.UnitTrackingDeviceId, + credential.UnitTrackingCredentialId, + DepartmentId, + UserId); + + credential.RevokedOn.Should().NotBeNull(); + result.SecretHash.Should().BeNull(); + _authenticationService.Verify( + service => service.InvalidateCredentialAsync("credential-hash"), + Times.Once); + _auditEvents.Should().ContainSingle(audit => + audit.Type == AuditLogTypes.UnitTrackingCredentialRevoked); + } + + [Test] + public async Task DisableDeviceAsync_ActiveDevice_RevokesCredentialsAtomically() + { + var device = Device(); + var credentials = new List + { + Credential("credential-1", "hash-1"), + Credential("credential-2", "hash-2") + }; + _devicesRepository + .Setup(repository => repository.GetByIdAsync(device.UnitTrackingDeviceId)) + .ReturnsAsync(device); + _credentialsRepository + .Setup(repository => repository.GetAllByDeviceIdAsync(device.UnitTrackingDeviceId)) + .ReturnsAsync(credentials); + + var result = await _service.DisableDeviceAsync( + device.UnitTrackingDeviceId, + DepartmentId, + UserId); + + result.IsEnabled.Should().BeFalse(); + result.LastStatus.Should().Be((int)UnitTrackingDeviceStatus.Disabled); + credentials.Should().OnlyContain(credential => credential.RevokedOn.HasValue); + _unitOfWork.Verify(unitOfWork => unitOfWork.CommitChanges(), Times.Once); + _auditEvents.Should().ContainSingle(audit => + audit.Type == AuditLogTypes.UnitTrackingDeviceDisabled); + } + + [Test] + public async Task RebindDeviceAsync_NewUnit_SoftDeletesOldBindingAndCreatesReplacement() + { + var device = Device(); + _devicesRepository + .Setup(repository => repository.GetByIdAsync(device.UnitTrackingDeviceId)) + .ReturnsAsync(device); + _credentialsRepository + .Setup(repository => repository.GetAllByDeviceIdAsync(device.UnitTrackingDeviceId)) + .ReturnsAsync(new List()); + + var result = await _service.RebindDeviceAsync( + device.UnitTrackingDeviceId, + DepartmentId, + 99, + UserId); + + device.IsDeleted.Should().BeTrue(); + device.IsEnabled.Should().BeFalse(); + result.UnitTrackingDeviceId.Should().NotBe(device.UnitTrackingDeviceId); + result.UnitId.Should().Be(99); + result.DeviceIdentifier.Should().Be(device.DeviceIdentifier); + result.IsEnabled.Should().BeTrue(); + _unitOfWork.Verify(unitOfWork => unitOfWork.CommitChanges(), Times.Once); + _auditEvents.Should().Contain(audit => audit.Type == AuditLogTypes.UnitTrackingDeviceDeleted); + _auditEvents.Should().Contain(audit => audit.Type == AuditLogTypes.UnitTrackingDeviceCreated); + } + + private static UnitTrackingDevice Device() + { + return new UnitTrackingDevice + { + UnitTrackingDeviceId = "device-1", + DepartmentId = DepartmentId, + UnitId = UnitId, + DisplayName = "Engine Tracker", + TransportType = (int)UnitTrackingTransportType.NativeTcpUdp, + ProtocolKey = "gt06", + DeviceIdentifier = "DEVICE-1234", + IsEnabled = true, + SourcePriority = 100, + CreatedByUserId = UserId, + CreatedOn = DateTime.UtcNow.AddDays(-1) + }; + } + + private static UnitTrackingCredential Credential(string credentialId, string secretHash) + { + return new UnitTrackingCredential + { + UnitTrackingCredentialId = credentialId, + UnitTrackingDeviceId = "device-1", + AuthMode = (int)UnitTrackingAuthMode.Bearer, + KeyPrefix = "prefix12", + SecretHash = secretHash, + ValidFrom = DateTime.UtcNow.AddDays(-1), + CreatedByUserId = UserId, + CreatedOn = DateTime.UtcNow.AddDays(-1) + }; + } + } +} diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingStatusServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingStatusServiceTests.cs new file mode 100644 index 000000000..93f8fadf9 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/UnitTrackingStatusServiceTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class UnitTrackingStatusServiceTests + { + private readonly DateTime _now = + new(2026, 7, 24, 20, 0, 0, DateTimeKind.Utc); + + private Mock _settingsService; + private UnitTrackingStatusService _service; + + [SetUp] + public void SetUp() + { + _settingsService = new Mock(); + _settingsService + .Setup(service => service.GetHardwareTrackingStaleAfterSecondsAsync(10, false)) + .ReturnsAsync(300); + _service = new UnitTrackingStatusService(_settingsService.Object); + } + + [TestCase(false, false, UnitTrackingDeviceStatus.Disabled)] + [TestCase(true, true, UnitTrackingDeviceStatus.Disabled)] + public async Task GetEffectiveStatusAsync_DisabledOrDeleted_ReturnsDisabled( + bool enabled, + bool deleted, + UnitTrackingDeviceStatus expected) + { + // Arrange + var device = Device(); + device.IsEnabled = enabled; + device.IsDeleted = deleted; + + // Act + var status = await _service.GetEffectiveStatusAsync(device, _now); + + // Assert + status.Should().Be(expected); + } + + [Test] + public async Task GetEffectiveStatusAsync_NeverSeen_ReturnsNeverSeen() + { + // Arrange + var device = Device(); + + // Act + var status = await _service.GetEffectiveStatusAsync(device, _now); + + // Assert + status.Should().Be(UnitTrackingDeviceStatus.NeverSeen); + } + + [TestCase(-299, UnitTrackingDeviceStatus.Online)] + [TestCase(-301, UnitTrackingDeviceStatus.Stale)] + public async Task GetEffectiveStatusAsync_ReceiveAge_UsesDepartmentStaleThreshold( + int ageSeconds, + UnitTrackingDeviceStatus expected) + { + // Arrange + var device = Device(); + device.LastReceivedOn = _now.AddSeconds(ageSeconds); + + // Act + var status = await _service.GetEffectiveStatusAsync(device, _now); + + // Assert + status.Should().Be(expected); + } + + [Test] + public async Task GetEffectiveStatusAsync_ErrorState_RemainsError() + { + // Arrange + var device = Device(); + device.LastStatus = (int)UnitTrackingDeviceStatus.Error; + device.LastReceivedOn = _now; + + // Act + var status = await _service.GetEffectiveStatusAsync(device, _now); + + // Assert + status.Should().Be(UnitTrackingDeviceStatus.Error); + } + + [Test] + public async Task GetEffectiveStatusAsync_NewerHeartbeatThanPosition_UsesLatestConnectivity() + { + // Arrange + var device = Device(); + device.LastReceivedOn = _now.AddMinutes(-10); + device.LastSeenOn = _now.AddMinutes(-1); + + // Act + var status = await _service.GetEffectiveStatusAsync(device, _now); + + // Assert + status.Should().Be(UnitTrackingDeviceStatus.Online); + } + + private static UnitTrackingDevice Device() => + new() + { + UnitTrackingDeviceId = "device-1", + DepartmentId = 10, + UnitId = 42, + IsEnabled = true + }; + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/CapabilityPathRedactionMiddlewareTests.cs b/Tests/Resgrid.Tests/Web/Services/CapabilityPathRedactionMiddlewareTests.cs new file mode 100644 index 000000000..b58b2b72f --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/CapabilityPathRedactionMiddlewareTests.cs @@ -0,0 +1,65 @@ +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using NUnit.Framework; +using Resgrid.Web.Services.Middleware; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + public class CapabilityPathRedactionMiddlewareTests + { + [Test] + public async Task InvokeAsync_CapabilityPath_HidesTokenFromDownstreamPipeline() + { + const string token = "rgtrk_prefix12_super-secret-capability"; + var context = new DefaultHttpContext(); + context.Request.Path = $"/api/v4/unit-trackers/c/{token}"; + string downstreamPath = null; + var middleware = new CapabilityPathRedactionMiddleware(nextContext => + { + downstreamPath = nextContext.Request.Path.Value; + return Task.CompletedTask; + }); + + await middleware.InvokeAsync(context); + + downstreamPath.Should().Be(CapabilityPathRedactionMiddleware.RedactedCapabilityPath); + downstreamPath.Should().NotContain(token); + context.Items[CapabilityPathRedactionMiddleware.CapabilityTokenItemKey] + .Should().Be(token); + } + + [Test] + public void RedactCapabilityUrl_FullUrl_PreservesQueryWithoutSecret() + { + const string token = "rgtrk_prefix12_super-secret-capability"; + var redacted = CapabilityPathRedactionMiddleware.RedactCapabilityUrl( + $"https://api.example/api/v4/unit-trackers/c/{token}?source=test"); + + redacted.Should().Be( + "https://api.example/api/v4/unit-trackers/c/[REDACTED]?source=test"); + redacted.Should().NotContain(token); + } + + [Test] + public async Task InvokeAsync_InvalidCapabilitySubpath_StillHidesToken() + { + const string token = "rgtrk_prefix12_super-secret-capability"; + var context = new DefaultHttpContext(); + context.Request.Path = $"/api/v4/unit-trackers/c/{token}/unexpected"; + string downstreamPath = null; + var middleware = new CapabilityPathRedactionMiddleware(nextContext => + { + downstreamPath = nextContext.Request.Path.Value; + return Task.CompletedTask; + }); + + await middleware.InvokeAsync(context); + + downstreamPath.Should().Be( + CapabilityPathRedactionMiddleware.RedactedCapabilityPath + "/unexpected"); + downstreamPath.Should().NotContain(token); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/UnitLocationControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/UnitLocationControllerTests.cs new file mode 100644 index 000000000..7207f8310 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/UnitLocationControllerTests.cs @@ -0,0 +1,78 @@ +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Models.v4.UnitLocation; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + public class UnitLocationControllerTests + { + private const int DepartmentId = 10; + private const int UnitId = 42; + + private Mock _unitsService; + private Mock _unitLocationEventProvider; + private UnitLocationController _controller; + + [SetUp] + public void SetUp() + { + _unitsService = new Mock(); + _unitLocationEventProvider = new Mock(); + + _unitsService + .Setup(service => service.GetUnitByIdAsync(UnitId)) + .ReturnsAsync(new Unit { UnitId = UnitId, DepartmentId = DepartmentId, Name = "Engine 42" }); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, "unit-location-user"), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")) + }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + + _controller = new UnitLocationController(_unitsService.Object, _unitLocationEventProvider.Object) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [TearDown] + public void TearDown() + { + ClaimsAuthorizationHelper._httpContextAccessor = null; + } + + [Test] + public async Task SetUnitLocation_ReturnsServiceUnavailable_WhenConfirmedPublishFails() + { + _unitLocationEventProvider + .Setup(provider => provider.EnqueueUnitLocationEventAsync(It.IsAny())) + .ReturnsAsync(false); + + var response = await _controller.SetUnitLocation(new UnitLocationInput + { + UnitId = UnitId.ToString(), + Latitude = "47.6062", + Longitude = "-122.3321" + }); + + response.Result.Should().BeOfType() + .Which.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/UnitTrackingDevicesControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/UnitTrackingDevicesControllerTests.cs new file mode 100644 index 000000000..1e74bb7b8 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/UnitTrackingDevicesControllerTests.cs @@ -0,0 +1,359 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Model.Tracking; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Models.v4.UnitTracking; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + [NonParallelizable] + public class UnitTrackingDevicesControllerTests + { + private const int DepartmentId = 10; + private const int UnitId = 42; + private const string UserId = "tracking-admin"; + + private Mock _trackingService; + private Mock _catalogService; + private Mock _statusService; + private Mock _identifierService; + private Mock _authorizationService; + private UnitTrackingDevicesController _controller; + private UnitTrackingDevice _device; + private UnitTrackingCatalogProfile _profile; + private string _originalPublicHttpsBaseUrl; + + [SetUp] + public void SetUp() + { + _originalPublicHttpsBaseUrl = UnitTrackingConfig.PublicHttpsBaseUrl; + UnitTrackingConfig.PublicHttpsBaseUrl = "https://tracking.example"; + + _trackingService = new Mock(); + _catalogService = new Mock(); + _statusService = new Mock(); + _identifierService = new Mock(); + _authorizationService = new Mock(); + _device = new UnitTrackingDevice + { + UnitTrackingDeviceId = "device-1", + DepartmentId = DepartmentId, + UnitId = UnitId, + DisplayName = "Engine tracker", + ManufacturerKey = "generic", + ModelKey = "generic-https", + TransportType = (int)UnitTrackingTransportType.NativeHttps, + ProtocolKey = "resgrid-json", + PayloadAdapterKey = "resgrid-json-v1", + DeviceIdentifier = "DEVICE-1234", + IsEnabled = true, + SourcePriority = 100, + CreatedOn = DateTime.UtcNow + }; + _profile = new UnitTrackingCatalogProfile + { + Key = "generic-https", + ManufacturerKey = "generic", + ManufacturerName = "Generic", + Model = "Resgrid JSON", + TransportType = UnitTrackingTransportType.NativeHttps, + ProtocolKey = "resgrid-json", + PayloadAdapterKey = "resgrid-json-v1", + CertificationStatus = UnitTrackingCertificationStatus.Certified, + IsSelectable = true, + SupportedAuthModes = new[] + { + UnitTrackingAuthMode.Bearer, + UnitTrackingAuthMode.CapabilityPath + } + }; + + _trackingService + .Setup(service => service.GetDeviceByIdAsync("device-1", DepartmentId)) + .ReturnsAsync(_device); + _trackingService + .Setup(service => service.GetDevicesForUnitAsync(DepartmentId, UnitId)) + .ReturnsAsync(new List { _device }); + _trackingService + .Setup(service => service.GetCredentialsForDeviceAsync("device-1", DepartmentId)) + .ReturnsAsync(new List()); + _catalogService + .Setup(service => service.GetProfileAsync( + "generic-https", + It.IsAny())) + .ReturnsAsync(_profile); + _catalogService + .Setup(service => service.GetProfilesAsync(It.IsAny())) + .ReturnsAsync(new[] { _profile }); + _statusService + .Setup(service => service.GetEffectiveStatusAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(UnitTrackingDeviceStatus.Online); + _identifierService + .Setup(service => service.Mask("DEVICE-1234")) + .Returns("********1234"); + _authorizationService + .Setup(service => service.CanUserViewUnitAsync(UserId, UnitId)) + .ReturnsAsync(true); + _authorizationService + .Setup(service => service.CanUserModifyUnitAsync(UserId, UnitId)) + .ReturnsAsync(true); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")) + }; + ClaimsAuthorizationHelper._httpContextAccessor = + new HttpContextAccessor { HttpContext = httpContext }; + + _controller = new UnitTrackingDevicesController( + _trackingService.Object, + _catalogService.Object, + _statusService.Object, + _identifierService.Object, + _authorizationService.Object) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [TearDown] + public void TearDown() + { + UnitTrackingConfig.PublicHttpsBaseUrl = _originalPublicHttpsBaseUrl; + ClaimsAuthorizationHelper._httpContextAccessor = null; + } + + [Test] + public void AdministrativeActions_UseExpectedUnitPolicies() + { + // Arrange + var viewActions = new[] + { + nameof(UnitTrackingDevicesController.GetCatalog), + nameof(UnitTrackingDevicesController.GetUnitTrackers), + nameof(UnitTrackingDevicesController.GetTracker), + nameof(UnitTrackingDevicesController.GetStatus) + }; + var updateActions = new[] + { + nameof(UnitTrackingDevicesController.CreateTracker), + nameof(UnitTrackingDevicesController.UpdateTracker), + nameof(UnitTrackingDevicesController.CreateCredential), + nameof(UnitTrackingDevicesController.RotateCredential), + nameof(UnitTrackingDevicesController.RevokeCredential), + nameof(UnitTrackingDevicesController.DisableTracker), + nameof(UnitTrackingDevicesController.DeleteTracker), + nameof(UnitTrackingDevicesController.RebindTracker) + }; + + // Act + var controllerType = typeof(UnitTrackingDevicesController); + + // Assert + foreach (var actionName in viewActions) + PolicyFor(controllerType, actionName).Should().Be(ResgridResources.Unit_View); + foreach (var actionName in updateActions) + PolicyFor(controllerType, actionName).Should().Be(ResgridResources.Unit_Update); + } + + [Test] + public async Task GetTracker_ViewOnlyUser_ReturnsMaskedIdentifierAndSanitizedCredentials() + { + // Arrange + _authorizationService + .Setup(service => service.CanUserModifyUnitAsync(UserId, UnitId)) + .ReturnsAsync(false); + _trackingService + .Setup(service => service.GetCredentialsForDeviceAsync("device-1", DepartmentId)) + .ReturnsAsync(new List + { + new() + { + UnitTrackingCredentialId = "credential-1", + UnitTrackingDeviceId = "device-1", + AuthMode = (int)UnitTrackingAuthMode.Bearer, + KeyPrefix = "prefix12", + SecretHash = "must-not-be-returned", + ValidFrom = DateTime.UtcNow, + CreatedOn = DateTime.UtcNow + } + }); + + // Act + var result = await _controller.GetTracker("device-1", CancellationToken.None); + + // Assert + var response = result.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + response.DeviceIdentifier.Should().Be("********1234"); + response.IdentifierMasked.Should().BeTrue(); + response.AllowedSourceCidrs.Should().BeNull(); + response.Credentials.Should().ContainSingle() + .Which.KeyPrefix.Should().Be("prefix12"); + typeof(UnitTrackingCredentialData).GetProperty(nameof(UnitTrackingCredential.SecretHash)) + .Should().BeNull(); + } + + [Test] + public async Task CreateTracker_ValidProfile_BindsRouteUnitAndCallerDepartment() + { + // Arrange + UnitTrackingDevice captured = null; + _trackingService + .Setup(service => service.CreateDeviceAsync( + It.IsAny(), + DepartmentId, + UserId, + It.IsAny())) + .Callback( + (device, departmentId, userId, cancellationToken) => captured = device) + .ReturnsAsync(_device); + + // Act + var result = await _controller.CreateTracker( + UnitId, + new CreateUnitTrackingDeviceInput + { + ProfileKey = "generic-https", + DisplayName = "Engine tracker", + DeviceIdentifier = "device-1234" + }, + CancellationToken.None); + + // Assert + result.Result.Should().BeOfType(); + captured.Should().NotBeNull(); + captured.UnitId.Should().Be(UnitId); + captured.PayloadAdapterKey.Should().Be("resgrid-json-v1"); + captured.TransportType.Should().Be((int)UnitTrackingTransportType.NativeHttps); + } + + [Test] + public async Task CreateTracker_UnitOutsideCallerScope_ReturnsForbiddenWithoutWrite() + { + // Arrange + _authorizationService + .Setup(service => service.CanUserModifyUnitAsync(UserId, 99)) + .ReturnsAsync(false); + + // Act + var result = await _controller.CreateTracker( + 99, + new CreateUnitTrackingDeviceInput { ProfileKey = "generic-https" }, + CancellationToken.None); + + // Assert + result.Result.Should().BeOfType(); + _trackingService.Verify(service => service.CreateDeviceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task CreateCredential_Bearer_ReturnsTokenAndEndpointOnlyInProvisioningResponse() + { + // Arrange + _trackingService + .Setup(service => service.CreateCredentialAsync( + "device-1", + DepartmentId, + UnitTrackingAuthMode.Bearer, + UserId, + null, + null, + It.IsAny())) + .ReturnsAsync(new UnitTrackingCredentialProvisionResult + { + Token = "one-time-token", + EndpointUrl = "https://tracking.example/api/v4/unit-trackers/device-1/positions", + HeaderName = "Authorization", + HeaderValue = "Bearer one-time-token", + Credential = new UnitTrackingCredential + { + UnitTrackingCredentialId = "credential-1", + UnitTrackingDeviceId = "device-1", + AuthMode = (int)UnitTrackingAuthMode.Bearer, + KeyPrefix = "prefix12", + ValidFrom = DateTime.UtcNow, + CreatedOn = DateTime.UtcNow + } + }); + + // Act + var result = await _controller.CreateCredential( + "device-1", + new CreateUnitTrackingCredentialInput + { + AuthMode = (int)UnitTrackingAuthMode.Bearer + }, + CancellationToken.None); + + // Assert + var response = result.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + response.Token.Should().Be("one-time-token"); + response.EndpointUrl.Should().Be( + "https://tracking.example/api/v4/unit-trackers/device-1/positions"); + response.HeaderName.Should().Be("Authorization"); + response.HeaderValue.Should().Be("Bearer one-time-token"); + _controller.Response.Headers.CacheControl.ToString().Should().Contain("no-store"); + } + + [Test] + public async Task GetStatus_TrackingStatusServiceResult_IsExposedWithoutCredentialData() + { + // Arrange + _statusService + .Setup(service => service.GetEffectiveStatusAsync( + _device, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(UnitTrackingDeviceStatus.Stale); + + // Act + var result = await _controller.GetStatus("device-1", CancellationToken.None); + + // Assert + var response = result.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + response.Status.Should().Be((int)UnitTrackingDeviceStatus.Stale); + response.StatusName.Should().Be(nameof(UnitTrackingDeviceStatus.Stale)); + response.Credentials.Should().BeEmpty(); + } + + private static string PolicyFor(Type controllerType, string actionName) + { + return controllerType + .GetMethod(actionName, BindingFlags.Instance | BindingFlags.Public) + .GetCustomAttributes() + .Single() + .Policy; + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/UnitTrackingHttpAuthenticationServiceTests.cs b/Tests/Resgrid.Tests/Web/Services/UnitTrackingHttpAuthenticationServiceTests.cs new file mode 100644 index 000000000..79f9b73b6 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/UnitTrackingHttpAuthenticationServiceTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Web.Services.ApplicationCore.UnitTracking; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + public class UnitTrackingHttpAuthenticationServiceTests + { + private Mock _authenticationService; + private UnitTrackingHttpAuthenticationService _service; + private UnitTrackingDevice _device; + + [SetUp] + public void SetUp() + { + _authenticationService = new Mock(); + _service = new UnitTrackingHttpAuthenticationService(_authenticationService.Object); + _device = new UnitTrackingDevice + { + UnitTrackingDeviceId = "device-1", + IsEnabled = true + }; + _authenticationService + .Setup(service => service.GetEnabledDeviceByEndpointIdAsync( + "device-1", + It.IsAny())) + .ReturnsAsync(_device); + } + + [Test] + public async Task AuthenticateEndpointAsync_BearerTokenForRequestedDevice_Succeeds() + { + var request = Request(); + request.Headers.Authorization = "Bearer secret-token"; + var credential = Credential(UnitTrackingAuthMode.Bearer); + SetupToken("secret-token", _device, credential); + + var result = await _service.AuthenticateEndpointAsync(request, "device-1"); + + result.Status.Should().Be(UnitTrackingHttpAuthenticationStatus.Authenticated); + result.Source.Device.Should().BeSameAs(_device); + result.Source.Credential.Should().BeSameAs(credential); + } + + [Test] + public async Task AuthenticateEndpointAsync_BasicUsernameMismatch_IsUnauthorized() + { + var request = Request(); + request.Headers.Authorization = + "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("wrong-user:secret-token")); + var credential = Credential(UnitTrackingAuthMode.Basic); + credential.BasicUsername = "configured-user"; + SetupToken("secret-token", _device, credential); + + var result = await _service.AuthenticateEndpointAsync(request, "device-1"); + + result.Status.Should().Be(UnitTrackingHttpAuthenticationStatus.Unauthorized); + } + + [Test] + public async Task AuthenticateEndpointAsync_ConfiguredCustomHeader_Succeeds() + { + var request = Request(); + request.Headers["X-Vendor-Tracker-Key"] = "secret-token"; + var credential = Credential(UnitTrackingAuthMode.CustomHeader); + credential.HeaderName = "X-Vendor-Tracker-Key"; + _authenticationService + .Setup(service => service.GetActiveCredentialsForDeviceAsync( + "device-1", + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new List { credential }); + SetupToken("secret-token", _device, credential); + + var result = await _service.AuthenticateEndpointAsync(request, "device-1"); + + result.Status.Should().Be(UnitTrackingHttpAuthenticationStatus.Authenticated); + } + + [Test] + public async Task AuthenticateEndpointAsync_TokenBoundToDifferentEndpoint_IsUnauthorized() + { + var request = Request(); + request.Headers.Authorization = "Bearer secret-token"; + var otherDevice = new UnitTrackingDevice + { + UnitTrackingDeviceId = "device-2", + IsEnabled = true + }; + SetupToken("secret-token", otherDevice, Credential(UnitTrackingAuthMode.Bearer)); + + var result = await _service.AuthenticateEndpointAsync(request, "device-1"); + + result.Status.Should().Be(UnitTrackingHttpAuthenticationStatus.Unauthorized); + } + + [Test] + public async Task AuthenticateCapabilityAsync_NonCapabilityCredential_ReturnsNotFound() + { + SetupToken("capability-token", _device, Credential(UnitTrackingAuthMode.Bearer)); + + var result = await _service.AuthenticateCapabilityAsync("capability-token"); + + result.Status.Should().Be(UnitTrackingHttpAuthenticationStatus.NotFound); + } + + private void SetupToken( + string token, + UnitTrackingDevice device, + UnitTrackingCredential credential) + { + _authenticationService + .Setup(service => service.AuthenticateAsync( + token, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UnitTrackingAuthenticationResult + { + Device = device, + Credential = credential + }); + } + + private static UnitTrackingCredential Credential(UnitTrackingAuthMode mode) => + new() + { + UnitTrackingCredentialId = "credential-1", + UnitTrackingDeviceId = "device-1", + AuthMode = (int)mode + }; + + private static HttpRequest Request() => new DefaultHttpContext().Request; + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/UnitTrackingIngressControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/UnitTrackingIngressControllerTests.cs new file mode 100644 index 000000000..a0d87e9d3 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/UnitTrackingIngressControllerTests.cs @@ -0,0 +1,316 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Model.Tracking; +using Resgrid.Web.Services.ApplicationCore.UnitTracking; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Middleware; +using Resgrid.Web.Services.Models.v4.UnitTracking; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + [NonParallelizable] + public class UnitTrackingIngressControllerTests + { + private Mock _authenticationService; + private Mock _ingressService; + private UnitTrackingIngressController _controller; + private MemoryCache _cache; + private UnitTrackingDevice _device; + private UnitTrackingCredential _credential; + private bool _originalEnabled; + private bool _originalHttpsEnabled; + private int _originalRequestsPerMinute; + + [SetUp] + public void SetUp() + { + _originalEnabled = UnitTrackingConfig.Enabled; + _originalHttpsEnabled = UnitTrackingConfig.HttpsIngressEnabled; + _originalRequestsPerMinute = UnitTrackingConfig.PerDeviceRequestsPerMinute; + UnitTrackingConfig.Enabled = true; + UnitTrackingConfig.HttpsIngressEnabled = true; + UnitTrackingConfig.PerDeviceRequestsPerMinute = 120; + + _authenticationService = new Mock(); + _ingressService = new Mock(); + _cache = new MemoryCache(new MemoryCacheOptions()); + _device = new UnitTrackingDevice + { + UnitTrackingDeviceId = "device-1", + DepartmentId = 10, + UnitId = 42, + IsEnabled = true, + PayloadAdapterKey = "resgrid-json-v1" + }; + _credential = new UnitTrackingCredential + { + UnitTrackingCredentialId = "credential-1", + UnitTrackingDeviceId = "device-1", + AuthMode = (int)UnitTrackingAuthMode.Bearer + }; + _authenticationService + .Setup(service => service.GetEnabledDeviceByEndpointIdAsync( + "device-1", + It.IsAny())) + .ReturnsAsync(_device); + _authenticationService + .Setup(service => service.AuthenticateAsync( + "secret-token", + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UnitTrackingAuthenticationResult + { + Device = _device, + Credential = _credential + }); + _ingressService + .Setup(service => service.AcceptAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TrackingIngressResult + { + Status = TrackingIngressStatus.Accepted, + Accepted = 1, + ReceivedOn = DateTime.UtcNow + }); + + var httpContext = Context(""" + { + "eventId": "record-1", + "latitude": 39.7392, + "longitude": -104.9903 + } + """); + httpContext.Request.Headers.Authorization = "Bearer secret-token"; + _controller = new UnitTrackingIngressController( + new UnitTrackingHttpAuthenticationService(_authenticationService.Object), + new UnitTrackingJsonPayloadParser(), + new UnitTrackingRateLimiter(_cache), + _ingressService.Object) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [TearDown] + public void TearDown() + { + _cache.Dispose(); + UnitTrackingConfig.Enabled = _originalEnabled; + UnitTrackingConfig.HttpsIngressEnabled = _originalHttpsEnabled; + UnitTrackingConfig.PerDeviceRequestsPerMinute = _originalRequestsPerMinute; + } + + [Test] + public async Task PostPositions_ValidBearerPayload_ReturnsAcceptedWithoutTenantData() + { + var result = await _controller.PostPositions("device-1"); + + var accepted = result.Should().BeOfType().Subject; + var response = accepted.Value.Should() + .BeOfType().Subject; + response.Accepted.Should().Be(1); + _ingressService.Verify(service => service.AcceptAsync( + It.Is(source => + source.Device == _device && + source.Credential == _credential), + It.IsAny>(), + It.IsAny()), Times.Once); + } + + [Test] + public async Task PostPositions_KnownEndpointWithInvalidCredential_ReturnsUnauthorized() + { + _controller.Request.Headers.Authorization = "Bearer wrong-token"; + + var result = await _controller.PostPositions("device-1"); + + result.Should().BeOfType(); + _ingressService.Verify(service => service.AcceptAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task PostPositions_UnknownEndpoint_ReturnsNotFound() + { + _authenticationService + .Setup(service => service.GetEnabledDeviceByEndpointIdAsync( + "missing-device", + It.IsAny())) + .ReturnsAsync((UnitTrackingDevice)null); + + var result = await _controller.PostPositions("missing-device"); + + result.Should().BeOfType(); + } + + [Test] + public async Task PostPositions_OneInvalidBatchRecord_ReturnsUnprocessableWithoutIngressCall() + { + SetBody(""" + { + "positions": [ + { "eventId": "1", "latitude": 1, "longitude": 2 }, + { "eventId": "2", "latitude": 1 } + ] + } + """); + + var result = await _controller.PostPositions("device-1"); + + result.Should().BeOfType(); + _ingressService.Verify(service => service.AcceptAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task PostPositions_MalformedJson_ReturnsBadRequest() + { + SetBody("{"); + + var result = await _controller.PostPositions("device-1"); + + result.Should().BeOfType(); + } + + [Test] + public async Task PostPositions_UnsupportedContentType_ReturnsUnsupportedMediaType() + { + _controller.Request.ContentType = "text/plain"; + + var result = await _controller.PostPositions("device-1"); + + result.Should().BeOfType() + .Which.StatusCode.Should().Be(StatusCodes.Status415UnsupportedMediaType); + } + + [Test] + public async Task PostPositions_DeclaredBodyOverLimit_ReturnsPayloadTooLarge() + { + _controller.Request.ContentLength = UnitTrackingConfig.MaxRequestBytes + 1L; + + var result = await _controller.PostPositions("device-1"); + + result.Should().BeOfType() + .Which.StatusCode.Should().Be(StatusCodes.Status413PayloadTooLarge); + } + + [Test] + public async Task PostPositions_RequestLimitExceeded_ReturnsRetryAfter() + { + UnitTrackingConfig.PerDeviceRequestsPerMinute = 1; + + (await _controller.PostPositions("device-1")) + .Should().BeOfType(); + SetBody( + "{\"eventId\":\"record-2\",\"latitude\":39.7392,\"longitude\":-104.9903}"); + + var result = await _controller.PostPositions("device-1"); + + result.Should().BeOfType() + .Which.StatusCode.Should().Be(StatusCodes.Status429TooManyRequests); + _controller.Response.Headers.RetryAfter.ToString().Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public async Task PostPositions_QueueUnavailable_ReturnsServiceUnavailable() + { + _ingressService + .Setup(service => service.AcceptAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TrackingIngressResult + { + Status = TrackingIngressStatus.Unavailable, + ReceivedOn = DateTime.UtcNow + }); + + var result = await _controller.PostPositions("device-1"); + + result.Should().BeOfType() + .Which.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + } + + [Test] + public async Task PostPositions_AuthenticationDependencyFailure_ReturnsServiceUnavailable() + { + _authenticationService + .Setup(service => service.GetEnabledDeviceByEndpointIdAsync( + "device-1", + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("credential store unavailable")); + + var result = await _controller.PostPositions("device-1"); + + result.Should().BeOfType() + .Which.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + } + + [Test] + public async Task PostPositions_IngressDependencyFailure_ReturnsServiceUnavailable() + { + _ingressService + .Setup(service => service.AcceptAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("settings store unavailable")); + + var result = await _controller.PostPositions("device-1"); + + result.Should().BeOfType() + .Which.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + } + + [Test] + public async Task PostCapability_RedactedRouteUsesMiddlewareTokenAndReturnsAccepted() + { + _credential.AuthMode = (int)UnitTrackingAuthMode.CapabilityPath; + _controller.HttpContext.Items[ + CapabilityPathRedactionMiddleware.CapabilityTokenItemKey] = "secret-token"; + _controller.Request.Headers.Remove("Authorization"); + + var result = await _controller.PostCapability("[REDACTED]"); + + result.Should().BeOfType(); + } + + private void SetBody(string json) + { + var bytes = Encoding.UTF8.GetBytes(json); + _controller.Request.Body = new MemoryStream(bytes); + _controller.Request.ContentLength = bytes.Length; + _controller.Request.ContentType = "application/json"; + } + + private static DefaultHttpContext Context(string json) + { + var context = new DefaultHttpContext(); + var bytes = Encoding.UTF8.GetBytes(json); + context.Request.Body = new MemoryStream(bytes); + context.Request.ContentLength = bytes.Length; + context.Request.ContentType = "application/json"; + context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("203.0.113.10"); + return context; + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/UnitTrackingJsonPayloadParserTests.cs b/Tests/Resgrid.Tests/Web/Services/UnitTrackingJsonPayloadParserTests.cs new file mode 100644 index 000000000..36b5eae35 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/UnitTrackingJsonPayloadParserTests.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Web.Services.ApplicationCore.UnitTracking; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + [NonParallelizable] + public class UnitTrackingJsonPayloadParserTests + { + private UnitTrackingJsonPayloadParser _parser; + private int _originalMaxRequestBytes; + private int _originalMaxBatchPositions; + private int _originalMaxJsonDepth; + + [SetUp] + public void SetUp() + { + _parser = new UnitTrackingJsonPayloadParser(); + _originalMaxRequestBytes = UnitTrackingConfig.MaxRequestBytes; + _originalMaxBatchPositions = UnitTrackingConfig.MaxBatchPositions; + _originalMaxJsonDepth = UnitTrackingConfig.MaxJsonDepth; + UnitTrackingConfig.MaxRequestBytes = 262144; + UnitTrackingConfig.MaxBatchPositions = 100; + UnitTrackingConfig.MaxJsonDepth = 16; + } + + [TearDown] + public void TearDown() + { + UnitTrackingConfig.MaxRequestBytes = _originalMaxRequestBytes; + UnitTrackingConfig.MaxBatchPositions = _originalMaxBatchPositions; + UnitTrackingConfig.MaxJsonDepth = _originalMaxJsonDepth; + } + + [Test] + public async Task ParseAsync_SingleGenericPayload_MapsCanonicalUnitsAndServerTimestamp() + { + var receivedOn = new DateTime(2026, 7, 24, 18, 42, 52, DateTimeKind.Utc); + var request = Request(""" + { + "eventId": "device-record-1", + "latitude": 39.7392, + "longitude": -104.9903, + "speedMetersPerSecond": 13.4, + "moving": true, + "deviceIdentifier": "device-1234", + "unknownTelemetry": { "ignored": true } + } + """); + + var result = await _parser.ParseAsync(request, receivedOn); + + result.Status.Should().Be(UnitTrackingPayloadParseStatus.Success); + result.ReportedDeviceIdentifier.Should().Be("device-1234"); + result.Positions.Should().ContainSingle(); + var position = System.Linq.Enumerable.Single(result.Positions); + position.TimestampUtc.Should().Be(receivedOn); + position.TimestampSource.Should().Be(TrackingTimestampSource.Server); + position.SpeedMetersPerSecond.Should().Be(13.4m); + position.IsMoving.Should().BeTrue(); + } + + [Test] + public async Task ParseAsync_InvalidDeviceTimestamp_UsesServerTimestamp() + { + var receivedOn = new DateTime(2026, 7, 24, 18, 42, 52, DateTimeKind.Utc); + var request = Request( + "{\"eventId\":\"record\",\"timestamp\":\"not-a-device-time\",\"latitude\":1,\"longitude\":2}"); + + var result = await _parser.ParseAsync(request, receivedOn); + + result.Status.Should().Be(UnitTrackingPayloadParseStatus.Success); + var position = result.Positions.Should().ContainSingle().Subject; + position.TimestampUtc.Should().Be(receivedOn); + position.TimestampSource.Should().Be(TrackingTimestampSource.Server); + } + + [Test] + public async Task ParseAsync_BatchMissingRequiredField_RejectsWholeBatch() + { + var request = Request(""" + { + "positions": [ + { "eventId": "1", "latitude": 1, "longitude": 2 }, + { "eventId": "2", "latitude": 1 } + ] + } + """); + + var result = await _parser.ParseAsync(request, DateTime.UtcNow); + + result.Status.Should().Be(UnitTrackingPayloadParseStatus.Invalid); + result.Errors.Should().Contain(error => error.Contains("positions[1]")); + result.Positions.Should().BeEmpty(); + } + + [Test] + public async Task ParseAsync_BodyExceedsConfiguredLimit_ReturnsTooLarge() + { + UnitTrackingConfig.MaxRequestBytes = 32; + var request = Request( + "{\"eventId\":\"record\",\"latitude\":1,\"longitude\":2,\"padding\":\"xxxxxxxxxxxxxxxx\"}"); + + var result = await _parser.ParseAsync(request, DateTime.UtcNow); + + result.Status.Should().Be(UnitTrackingPayloadParseStatus.TooLarge); + } + + [Test] + public async Task ParseAsync_JsonExceedsConfiguredDepth_ReturnsMalformed() + { + UnitTrackingConfig.MaxJsonDepth = 3; + var request = Request( + "{\"eventId\":\"record\",\"latitude\":1,\"longitude\":2,\"nested\":{\"a\":{\"b\":{\"c\":1}}}}"); + + var result = await _parser.ParseAsync(request, DateTime.UtcNow); + + result.Status.Should().Be(UnitTrackingPayloadParseStatus.Malformed); + } + + [Test] + public async Task ParseAsync_DuplicateRequiredProperty_ReturnsMalformed() + { + var request = Request( + "{\"eventId\":\"one\",\"eventId\":\"two\",\"latitude\":1,\"longitude\":2}"); + + var result = await _parser.ParseAsync(request, DateTime.UtcNow); + + result.Status.Should().Be(UnitTrackingPayloadParseStatus.Malformed); + } + + [Test] + public async Task ParseAsync_GzipContentEncoding_ReturnsUnsupportedMediaType() + { + var request = Request("{\"eventId\":\"record\",\"latitude\":1,\"longitude\":2}"); + request.Headers.ContentEncoding = "gzip"; + + var result = await _parser.ParseAsync(request, DateTime.UtcNow); + + result.Status.Should().Be(UnitTrackingPayloadParseStatus.UnsupportedMediaType); + } + + private static HttpRequest Request(string body) + { + var bytes = Encoding.UTF8.GetBytes(body); + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/json; charset=utf-8"; + context.Request.ContentLength = bytes.Length; + context.Request.Body = new MemoryStream(bytes); + return context.Request; + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/UnitTrackingNetworkPolicyTests.cs b/Tests/Resgrid.Tests/Web/Services/UnitTrackingNetworkPolicyTests.cs new file mode 100644 index 000000000..05dbea6ab --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/UnitTrackingNetworkPolicyTests.cs @@ -0,0 +1,30 @@ +using System.Net; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Web.Services.ApplicationCore.UnitTracking; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + public class UnitTrackingNetworkPolicyTests + { + [TestCase("10.10.2.15", "10.10.0.0/16", true)] + [TestCase("10.11.2.15", "10.10.0.0/16", false)] + [TestCase("203.0.113.9", "192.0.2.0/24, 203.0.113.9/32", true)] + [TestCase("2001:db8::42", "2001:db8::/32", true)] + public void IsAllowed_EnforcesConfiguredCidrs(string address, string ranges, bool expected) + { + UnitTrackingNetworkPolicy.IsAllowed(IPAddress.Parse(address), ranges) + .Should().Be(expected); + } + + [Test] + public void IsAllowed_InvalidConfiguredCidr_FailsClosed() + { + UnitTrackingNetworkPolicy.IsAllowed( + IPAddress.Parse("203.0.113.9"), + "not-a-cidr") + .Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/UnitTrackingRateLimiterTests.cs b/Tests/Resgrid.Tests/Web/Services/UnitTrackingRateLimiterTests.cs new file mode 100644 index 000000000..6043a4d39 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/UnitTrackingRateLimiterTests.cs @@ -0,0 +1,56 @@ +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Web.Services.ApplicationCore.UnitTracking; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + [NonParallelizable] + public class UnitTrackingRateLimiterTests + { + private int _originalRequestLimit; + private int _originalRecordLimit; + + [SetUp] + public void SetUp() + { + _originalRequestLimit = UnitTrackingConfig.PerDeviceRequestsPerMinute; + _originalRecordLimit = UnitTrackingConfig.PerDeviceRecordsPerMinute; + UnitTrackingConfig.PerDeviceRequestsPerMinute = 1; + UnitTrackingConfig.PerDeviceRecordsPerMinute = 2; + } + + [TearDown] + public void TearDown() + { + UnitTrackingConfig.PerDeviceRequestsPerMinute = _originalRequestLimit; + UnitTrackingConfig.PerDeviceRecordsPerMinute = _originalRecordLimit; + } + + [Test] + public void CheckRequest_SecondRequestWithinWindow_IsRateLimited() + { + using var cache = new MemoryCache(new MemoryCacheOptions()); + var limiter = new UnitTrackingRateLimiter(cache); + + limiter.CheckRequest("device-1", "credential-1").Allowed.Should().BeTrue(); + var second = limiter.CheckRequest("device-1", "credential-1"); + + second.Allowed.Should().BeFalse(); + second.RetryAfterSeconds.Should().BePositive(); + } + + [Test] + public void CheckRecords_BatchOverLimit_IsRateLimitedWithoutAllocationByRecord() + { + using var cache = new MemoryCache(new MemoryCacheOptions()); + var limiter = new UnitTrackingRateLimiter(cache); + + var result = limiter.CheckRecords("device-1", "credential-1", 3); + + result.Allowed.Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/UnitTrackingControllerTests.cs b/Tests/Resgrid.Tests/Web/User/UnitTrackingControllerTests.cs new file mode 100644 index 000000000..6cc881e92 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/UnitTrackingControllerTests.cs @@ -0,0 +1,338 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Localization; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Model.Tracking; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Areas.User.Models.UnitTracking; +using UnitsResource = Resgrid.Localization.Areas.User.Units.Units; + +namespace Resgrid.Tests.Web.User +{ + [TestFixture] + [NonParallelizable] + public class UnitTrackingControllerTests + { + private const int DepartmentId = 10; + private const int UnitId = 42; + private const string UserId = "tracking-admin"; + + private Mock _trackingService; + private Mock _catalogService; + private Mock _statusService; + private Mock _identifierService; + private Mock _unitsService; + private Mock _authorizationService; + private Mock> _localizer; + private UnitTrackingController _controller; + private Unit _unit; + private UnitTrackingDevice _device; + private UnitTrackingCatalogProfile _profile; + private SystemEnvironment _originalEnvironment; + + [SetUp] + public void SetUp() + { + _originalEnvironment = SystemBehaviorConfig.Environment; + SystemBehaviorConfig.Environment = SystemEnvironment.Dev; + + _trackingService = new Mock(); + _catalogService = new Mock(); + _statusService = new Mock(); + _identifierService = new Mock(); + _unitsService = new Mock(); + _authorizationService = new Mock(); + _localizer = new Mock>(); + + _unit = new Unit + { + UnitId = UnitId, + DepartmentId = DepartmentId, + Name = "Engine 42" + }; + _device = new UnitTrackingDevice + { + UnitTrackingDeviceId = "device-1", + DepartmentId = DepartmentId, + UnitId = UnitId, + ModelKey = "generic-https", + DeviceIdentifier = "DEVICE-1234", + IsEnabled = true + }; + _profile = new UnitTrackingCatalogProfile + { + Key = "generic-https", + ManufacturerKey = "generic", + ManufacturerName = "Generic", + Model = "Resgrid JSON", + TransportType = UnitTrackingTransportType.NativeHttps, + ProtocolKey = "resgrid-json", + PayloadAdapterKey = "resgrid-json-v1", + CertificationStatus = UnitTrackingCertificationStatus.Certified, + IsSelectable = true, + SupportedAuthModes = new[] + { + UnitTrackingAuthMode.Bearer, + UnitTrackingAuthMode.CapabilityPath + } + }; + + _unitsService + .Setup(service => service.GetUnitByIdAsync(UnitId)) + .ReturnsAsync(_unit); + _trackingService + .Setup(service => service.GetDeviceByIdAsync("device-1", DepartmentId)) + .ReturnsAsync(_device); + _trackingService + .Setup(service => service.GetDevicesForUnitAsync(DepartmentId, UnitId)) + .ReturnsAsync(new List { _device }); + _trackingService + .Setup(service => service.GetCredentialsForDeviceAsync("device-1", DepartmentId)) + .ReturnsAsync(new List()); + _catalogService + .Setup(service => service.GetProfileAsync( + "generic-https", + It.IsAny())) + .ReturnsAsync(_profile); + _catalogService + .Setup(service => service.GetProfilesAsync(It.IsAny())) + .ReturnsAsync(new[] { _profile }); + _statusService + .Setup(service => service.GetEffectiveStatusAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(UnitTrackingDeviceStatus.Online); + _identifierService + .Setup(service => service.Mask("DEVICE-1234")) + .Returns("********1234"); + _authorizationService + .Setup(service => service.CanUserViewUnitAsync(UserId, UnitId)) + .ReturnsAsync(true); + _authorizationService + .Setup(service => service.CanUserModifyUnitAsync(UserId, UnitId)) + .ReturnsAsync(true); + _localizer + .Setup(localizer => localizer[It.IsAny()]) + .Returns((string key) => new LocalizedString(key, key)); + _localizer + .Setup(localizer => localizer[It.IsAny(), It.IsAny()]) + .Returns((string key, object[] arguments) => + new LocalizedString(key, string.Format(key, arguments))); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()), + new Claim( + ResgridClaimTypes.Resources.Department, + ResgridClaimTypes.Actions.Update) + }, "test")) + }; + Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = + new HttpContextAccessor { HttpContext = httpContext }; + + _controller = new UnitTrackingController( + _trackingService.Object, + _catalogService.Object, + _statusService.Object, + _identifierService.Object, + _unitsService.Object, + _authorizationService.Object, + _localizer.Object) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + _controller.TempData = new TempDataDictionary( + httpContext, + Mock.Of()); + } + + [TearDown] + public void TearDown() + { + SystemBehaviorConfig.Environment = _originalEnvironment; + Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = null; + } + + [Test] + public void AdministrativeActions_UseExpectedUnitPolicies() + { + // Arrange + var expected = new Dictionary + { + [nameof(UnitTrackingController.Index)] = ResgridResources.Unit_View, + [nameof(UnitTrackingController.Details)] = ResgridResources.Unit_View, + [nameof(UnitTrackingController.New)] = ResgridResources.Unit_Update, + [nameof(UnitTrackingController.Edit)] = ResgridResources.Unit_Update, + [nameof(UnitTrackingController.CreateCredential)] = ResgridResources.Unit_Update, + [nameof(UnitTrackingController.RotateCredential)] = ResgridResources.Unit_Update, + [nameof(UnitTrackingController.RevokeCredential)] = ResgridResources.Unit_Update, + [nameof(UnitTrackingController.Disable)] = ResgridResources.Unit_Update, + [nameof(UnitTrackingController.Delete)] = ResgridResources.Unit_Update, + [nameof(UnitTrackingController.PreviewJson)] = ResgridResources.Unit_Update + }; + + // Act + var actions = typeof(UnitTrackingController) + .GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Where(method => expected.ContainsKey(method.Name)); + + // Assert + foreach (var action in actions) + { + action.GetCustomAttribute() + .Should().NotBeNull() + .And.Match(attribute => + attribute.Policy == expected[action.Name]); + } + } + + [Test] + public async Task Index_ViewOnlyUser_MasksDeviceIdentifier() + { + // Arrange + _authorizationService + .Setup(service => service.CanUserModifyUnitAsync(UserId, UnitId)) + .ReturnsAsync(false); + + // Act + var result = await _controller.Index(UnitId, CancellationToken.None); + + // Assert + var model = result.Should().BeOfType().Subject.Model + .Should().BeOfType().Subject; + model.CanManage.Should().BeFalse(); + model.Devices.Should().ContainSingle() + .Which.DisplayIdentifier.Should().Be("********1234"); + } + + [Test] + public async Task Index_UnitOutsideDepartment_RedirectsToUnauthorized() + { + // Arrange + _unit.DepartmentId = DepartmentId + 1; + + // Act + var result = await _controller.Index(UnitId, CancellationToken.None); + + // Assert + result.Should().BeOfType() + .Which.Url.Should().Be("/Public/Unauthorized"); + _trackingService.Verify(service => service.GetDevicesForUnitAsync( + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task CreateCredential_ValidBearer_DisplaysSecretOnceAndDisablesCaching() + { + // Arrange + _trackingService + .Setup(service => service.CreateCredentialAsync( + "device-1", + DepartmentId, + UnitTrackingAuthMode.Bearer, + UserId, + null, + null, + It.IsAny())) + .ReturnsAsync(new UnitTrackingCredentialProvisionResult + { + Token = "one-time-token", + EndpointUrl = "https://tracking.example/api/v4/unit-trackers/device-1/positions", + HeaderName = "Authorization", + HeaderValue = "Bearer one-time-token", + Credential = new UnitTrackingCredential + { + UnitTrackingCredentialId = "credential-1", + UnitTrackingDeviceId = "device-1", + AuthMode = (int)UnitTrackingAuthMode.Bearer, + KeyPrefix = "prefix12" + } + }); + + // Act + var result = await _controller.CreateCredential( + new CreateUnitTrackingCredentialView + { + UnitTrackingDeviceId = "device-1", + AuthMode = (int)UnitTrackingAuthMode.Bearer + }, + CancellationToken.None); + + // Assert + var view = result.Should().BeOfType().Subject; + view.ViewName.Should().Be("Credential"); + view.Model.Should().BeOfType() + .Which.Provisioning.Token.Should().Be("one-time-token"); + _controller.Response.Headers.CacheControl.ToString().Should().Contain("no-store"); + _controller.Response.Headers.Pragma.ToString().Should().Be("no-cache"); + } + + [Test] + public async Task PreviewJson_ProductionEnvironment_ReturnsNotFoundWithoutReadingDevice() + { + // Arrange + SystemBehaviorConfig.Environment = SystemEnvironment.Prod; + + // Act + var result = await _controller.PreviewJson( + new PreviewUnitTrackingJsonView + { + UnitTrackingDeviceId = "device-1", + JsonPayload = "{\"eventId\":\"preview\",\"latitude\":1,\"longitude\":2}" + }, + CancellationToken.None); + + // Assert + result.Should().BeOfType(); + _trackingService.Verify(service => service.GetDeviceByIdAsync( + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task PreviewJson_NonAdministrator_ReturnsNotFoundWithoutReadingDevice() + { + // Arrange + _controller.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")); + + // Act + var result = await _controller.PreviewJson( + new PreviewUnitTrackingJsonView + { + UnitTrackingDeviceId = "device-1", + JsonPayload = "{\"eventId\":\"preview\",\"latitude\":1,\"longitude\":2}" + }, + CancellationToken.None); + + // Assert + result.Should().BeOfType(); + _trackingService.Verify(service => service.GetDeviceByIdAsync( + It.IsAny(), + It.IsAny()), Times.Never); + } + } +} diff --git a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingHttpAuthenticationService.cs b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingHttpAuthenticationService.cs new file mode 100644 index 000000000..52526f666 --- /dev/null +++ b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingHttpAuthenticationService.cs @@ -0,0 +1,231 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Model.Tracking; + +namespace Resgrid.Web.Services.ApplicationCore.UnitTracking +{ + public enum UnitTrackingHttpAuthenticationStatus + { + Authenticated = 0, + NotFound = 1, + Unauthorized = 2 + } + + public sealed class UnitTrackingHttpAuthenticationResult + { + public UnitTrackingHttpAuthenticationStatus Status { get; set; } + public AuthenticatedTrackingSource Source { get; set; } + } + + public class UnitTrackingHttpAuthenticationService + { + private const int MaximumAuthorizationHeaderLength = 2048; + private readonly IUnitTrackingAuthenticationService _authenticationService; + + public UnitTrackingHttpAuthenticationService( + IUnitTrackingAuthenticationService authenticationService) + { + _authenticationService = authenticationService; + } + + public async Task AuthenticateEndpointAsync( + HttpRequest request, + string unitTrackingDeviceId, + CancellationToken cancellationToken = default) + { + var device = await _authenticationService.GetEnabledDeviceByEndpointIdAsync( + unitTrackingDeviceId, + cancellationToken); + if (device == null) + return Result(UnitTrackingHttpAuthenticationStatus.NotFound); + + var presented = await ExtractCredentialAsync(request, device, cancellationToken); + if (presented == null) + return Result(UnitTrackingHttpAuthenticationStatus.Unauthorized); + + var authenticated = await _authenticationService.AuthenticateAsync( + presented.Token, + cancellationToken: cancellationToken); + if (!MatchesEndpointCredential(device, authenticated, presented)) + return Result(UnitTrackingHttpAuthenticationStatus.Unauthorized); + + return Authenticated(authenticated); + } + + public async Task AuthenticateCapabilityAsync( + string capabilityToken, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(capabilityToken)) + return Result(UnitTrackingHttpAuthenticationStatus.NotFound); + + var authenticated = await _authenticationService.AuthenticateAsync( + capabilityToken, + cancellationToken: cancellationToken); + if (authenticated?.Credential == null || + authenticated.Credential.AuthMode != (int)UnitTrackingAuthMode.CapabilityPath) + return Result(UnitTrackingHttpAuthenticationStatus.NotFound); + + return Authenticated(authenticated); + } + + private async Task ExtractCredentialAsync( + HttpRequest request, + UnitTrackingDevice device, + CancellationToken cancellationToken) + { + var authorization = request.Headers[HeaderNames.Authorization].ToString(); + if (!string.IsNullOrWhiteSpace(authorization)) + { + if (authorization.Length > MaximumAuthorizationHeaderLength) + return null; + + if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + var token = authorization.Substring("Bearer ".Length).Trim(); + return string.IsNullOrWhiteSpace(token) + ? null + : new PresentedCredential(UnitTrackingAuthMode.Bearer, token, null, null); + } + + if (authorization.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + return ParseBasic(authorization.Substring("Basic ".Length).Trim()); + + return null; + } + + var credentials = await _authenticationService.GetActiveCredentialsForDeviceAsync( + device.UnitTrackingDeviceId, + cancellationToken: cancellationToken); + foreach (var credential in credentials.Where(item => + item.AuthMode == (int)UnitTrackingAuthMode.CustomHeader && + IsValidHeaderName(item.HeaderName))) + { + if (!request.Headers.TryGetValue(credential.HeaderName, out var values)) + continue; + + var token = values.FirstOrDefault()?.Trim(); + if (!string.IsNullOrWhiteSpace(token)) + { + return new PresentedCredential( + UnitTrackingAuthMode.CustomHeader, + token, + null, + credential.HeaderName); + } + } + + return null; + } + + private static PresentedCredential ParseBasic(string encoded) + { + if (string.IsNullOrWhiteSpace(encoded)) + return null; + + try + { + var decodedBytes = Convert.FromBase64String(encoded); + var decoded = Encoding.UTF8.GetString(decodedBytes); + var separator = decoded.IndexOf(':'); + if (separator <= 0 || separator == decoded.Length - 1) + return null; + + return new PresentedCredential( + UnitTrackingAuthMode.Basic, + decoded.Substring(separator + 1), + decoded.Substring(0, separator), + null); + } + catch (FormatException) + { + return null; + } + } + + private static bool MatchesEndpointCredential( + UnitTrackingDevice requestedDevice, + UnitTrackingAuthenticationResult authenticated, + PresentedCredential presented) + { + if (authenticated?.Device == null || authenticated.Credential == null) + return false; + if (!string.Equals( + requestedDevice.UnitTrackingDeviceId, + authenticated.Device.UnitTrackingDeviceId, + StringComparison.OrdinalIgnoreCase)) + return false; + if (authenticated.Credential.AuthMode != (int)presented.Mode) + return false; + + if (presented.Mode == UnitTrackingAuthMode.Basic && + !string.Equals( + authenticated.Credential.BasicUsername, + presented.Username, + StringComparison.Ordinal)) + return false; + + if (presented.Mode == UnitTrackingAuthMode.CustomHeader && + !string.Equals( + authenticated.Credential.HeaderName, + presented.HeaderName, + StringComparison.OrdinalIgnoreCase)) + return false; + + return true; + } + + private static bool IsValidHeaderName(string headerName) + { + if (string.IsNullOrWhiteSpace(headerName) || headerName.Length > 128) + return false; + + return headerName.All(character => + char.IsLetterOrDigit(character) || + "!#$%&'*+-.^_`|~".Contains(character)); + } + + private static UnitTrackingHttpAuthenticationResult Authenticated( + UnitTrackingAuthenticationResult authenticated) => + new() + { + Status = UnitTrackingHttpAuthenticationStatus.Authenticated, + Source = new AuthenticatedTrackingSource + { + Device = authenticated.Device, + Credential = authenticated.Credential + } + }; + + private static UnitTrackingHttpAuthenticationResult Result( + UnitTrackingHttpAuthenticationStatus status) => + new() { Status = status }; + + private sealed class PresentedCredential + { + public PresentedCredential( + UnitTrackingAuthMode mode, + string token, + string username, + string headerName) + { + Mode = mode; + Token = token; + Username = username; + HeaderName = headerName; + } + + public UnitTrackingAuthMode Mode { get; } + public string Token { get; } + public string Username { get; } + public string HeaderName { get; } + } + } +} diff --git a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingJsonPayloadParser.cs b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingJsonPayloadParser.cs new file mode 100644 index 000000000..0249b6b7b --- /dev/null +++ b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingJsonPayloadParser.cs @@ -0,0 +1,312 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Tracking; +using Resgrid.Web.Services.Models.v4.UnitTracking; + +namespace Resgrid.Web.Services.ApplicationCore.UnitTracking +{ + public enum UnitTrackingPayloadParseStatus + { + Success = 0, + Malformed = 1, + Invalid = 2, + TooLarge = 3, + UnsupportedMediaType = 4 + } + + public sealed class UnitTrackingPayloadParseResult + { + public UnitTrackingPayloadParseStatus Status { get; set; } + public IReadOnlyCollection Positions { get; set; } = + Array.Empty(); + public string ReportedDeviceIdentifier { get; set; } + public IReadOnlyCollection Errors { get; set; } = Array.Empty(); + } + + public class UnitTrackingJsonPayloadParser + { + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + public bool Supports(string payloadAdapterKey) => + string.Equals( + payloadAdapterKey?.Trim(), + "resgrid-json-v1", + StringComparison.OrdinalIgnoreCase); + + public async Task ParseAsync( + HttpRequest request, + DateTime receivedOn, + CancellationToken cancellationToken = default) + { + if (!IsJson(request.ContentType) || HasUnsupportedContentEncoding(request)) + return Result(UnitTrackingPayloadParseStatus.UnsupportedMediaType); + + var maximumBytes = Math.Max(1, UnitTrackingConfig.MaxRequestBytes); + if (request.ContentLength.HasValue && request.ContentLength.Value > maximumBytes) + return Result(UnitTrackingPayloadParseStatus.TooLarge); + + byte[] body; + try + { + body = await ReadBoundedBodyAsync(request.Body, maximumBytes, cancellationToken); + } + catch (PayloadTooLargeException) + { + return Result(UnitTrackingPayloadParseStatus.TooLarge); + } + + if (body.Length == 0) + return Result(UnitTrackingPayloadParseStatus.Malformed); + + JObject root; + try + { + var json = StrictUtf8.GetString(body); + using var stringReader = new StringReader(json); + using var jsonReader = new JsonTextReader(stringReader) + { + DateParseHandling = DateParseHandling.DateTime, + FloatParseHandling = FloatParseHandling.Decimal, + MaxDepth = Math.Max(1, UnitTrackingConfig.MaxJsonDepth) + }; + var token = JToken.ReadFrom( + jsonReader, + new JsonLoadSettings + { + DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error, + LineInfoHandling = LineInfoHandling.Ignore + }); + if (token is not JObject parsedRoot || HasTrailingContent(jsonReader)) + return Result(UnitTrackingPayloadParseStatus.Malformed); + root = parsedRoot; + } + catch (Exception ex) when ( + ex is JsonException || + ex is DecoderFallbackException || + ex is ArgumentException) + { + return Result(UnitTrackingPayloadParseStatus.Malformed); + } + + var serializer = JsonSerializer.Create(new JsonSerializerSettings + { + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + FloatParseHandling = FloatParseHandling.Decimal, + MissingMemberHandling = MissingMemberHandling.Ignore + }); + + UnitTrackingPositionInput[] inputs; + string envelopeIdentifier = null; + try + { + if (root.TryGetValue("positions", StringComparison.OrdinalIgnoreCase, out _)) + { + var batch = root.ToObject(serializer); + inputs = batch?.Positions; + envelopeIdentifier = batch?.DeviceIdentifier; + } + else + { + inputs = new[] { root.ToObject(serializer) }; + } + } + catch (JsonException) + { + return Result(UnitTrackingPayloadParseStatus.Malformed); + } + + if (inputs == null || inputs.Length == 0) + return Invalid("At least one position is required."); + if (inputs.Length > Math.Max(1, UnitTrackingConfig.MaxBatchPositions)) + return Result(UnitTrackingPayloadParseStatus.TooLarge); + + var errors = new List(); + var positions = new List(inputs.Length); + var identifiers = new HashSet(StringComparer.Ordinal); + + if (!string.IsNullOrWhiteSpace(envelopeIdentifier)) + identifiers.Add(envelopeIdentifier.Trim()); + + for (var index = 0; index < inputs.Length; index++) + { + var input = inputs[index]; + if (input == null) + { + errors.Add($"positions[{index}]: Position is required."); + continue; + } + if (string.IsNullOrWhiteSpace(input.EventId)) + errors.Add($"positions[{index}]: eventId is required."); + if (!input.Latitude.HasValue) + errors.Add($"positions[{index}]: latitude is required."); + if (!input.Longitude.HasValue) + errors.Add($"positions[{index}]: longitude is required."); + + if (!string.IsNullOrWhiteSpace(input.DeviceIdentifier)) + identifiers.Add(input.DeviceIdentifier.Trim()); + + if (input.Latitude.HasValue && input.Longitude.HasValue) + positions.Add(Map(input, receivedOn)); + } + + if (identifiers.Count > 1) + errors.Add("All reported device identifiers in a batch must match."); + if (errors.Count > 0) + return Invalid(errors); + + return new UnitTrackingPayloadParseResult + { + Status = UnitTrackingPayloadParseStatus.Success, + Positions = positions, + ReportedDeviceIdentifier = identifiers.SingleOrDefault() + }; + } + + private static CanonicalTrackingPosition Map( + UnitTrackingPositionInput input, + DateTime receivedOn) + { + var deviceTimestamp = ParseTimestamp(input.Timestamp); + var hasDeviceTimestamp = deviceTimestamp.HasValue; + return new CanonicalTrackingPosition + { + EventId = input.EventId, + TimestampUtc = hasDeviceTimestamp + ? EnsureUtc(deviceTimestamp.Value) + : receivedOn, + ReceivedOnUtc = receivedOn, + Latitude = input.Latitude.Value, + Longitude = input.Longitude.Value, + AccuracyMeters = input.AccuracyMeters, + AltitudeMeters = input.AltitudeMeters, + SpeedMetersPerSecond = input.SpeedMetersPerSecond, + HeadingDegrees = input.HeadingDegrees, + Satellites = input.Satellites, + Hdop = input.Hdop, + BatteryPercent = input.BatteryPercent, + ExternalPowerVolts = input.ExternalPowerVolts, + SignalPercent = input.SignalPercent, + Ignition = input.Ignition, + IsMoving = input.Moving, + AlarmCode = input.AlarmCode, + TimestampSource = hasDeviceTimestamp + ? TrackingTimestampSource.Device + : TrackingTimestampSource.Server, + IsValidFix = input.ValidFix ?? true + }; + } + + private static DateTime? ParseTimestamp(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + if (token.Type == JTokenType.Date) + return token.Value(); + + if (token.Type != JTokenType.String) + return null; + + return DateTimeOffset.TryParse( + token.Value(), + CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces | + DateTimeStyles.AssumeUniversal | + DateTimeStyles.AdjustToUniversal, + out var parsed) + ? parsed.UtcDateTime + : null; + } + + private static async Task ReadBoundedBodyAsync( + Stream body, + int maximumBytes, + CancellationToken cancellationToken) + { + using var buffer = new MemoryStream(Math.Min(maximumBytes, 16 * 1024)); + var chunk = new byte[Math.Min(8192, maximumBytes + 1)]; + var total = 0; + + while (true) + { + var read = await body.ReadAsync(chunk.AsMemory(0, chunk.Length), cancellationToken); + if (read == 0) + break; + + total += read; + if (total > maximumBytes) + throw new PayloadTooLargeException(); + + await buffer.WriteAsync(chunk.AsMemory(0, read), cancellationToken); + } + + return buffer.ToArray(); + } + + private static bool IsJson(string contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + return false; + + var mediaType = contentType.Split(';', 2)[0].Trim(); + return string.Equals(mediaType, "application/json", StringComparison.OrdinalIgnoreCase); + } + + private static bool HasUnsupportedContentEncoding(HttpRequest request) + { + if (!request.Headers.TryGetValue("Content-Encoding", out var encodings)) + return false; + + return encodings.Any(encoding => + !string.IsNullOrWhiteSpace(encoding) && + !string.Equals(encoding.Trim(), "identity", StringComparison.OrdinalIgnoreCase)); + } + + private static bool HasTrailingContent(JsonTextReader reader) + { + while (reader.Read()) + { + if (reader.TokenType != JsonToken.Comment) + return true; + } + + return false; + } + + private static DateTime EnsureUtc(DateTime value) => + value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc) + }; + + private static UnitTrackingPayloadParseResult Result(UnitTrackingPayloadParseStatus status) => + new() { Status = status }; + + private static UnitTrackingPayloadParseResult Invalid(string error) => + Invalid(new[] { error }); + + private static UnitTrackingPayloadParseResult Invalid(IReadOnlyCollection errors) => + new() + { + Status = UnitTrackingPayloadParseStatus.Invalid, + Errors = errors + }; + + private sealed class PayloadTooLargeException : Exception + { + } + } +} diff --git a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingNetworkPolicy.cs b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingNetworkPolicy.cs new file mode 100644 index 000000000..a929e99f0 --- /dev/null +++ b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingNetworkPolicy.cs @@ -0,0 +1,72 @@ +using System; +using System.Linq; +using System.Net; + +namespace Resgrid.Web.Services.ApplicationCore.UnitTracking +{ + public static class UnitTrackingNetworkPolicy + { + public static bool IsAllowed(IPAddress remoteAddress, string allowedSourceCidrs) + { + if (string.IsNullOrWhiteSpace(allowedSourceCidrs)) + return true; + if (remoteAddress == null) + return false; + + var ranges = allowedSourceCidrs + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return ranges.Length > 0 && ranges.Any(range => Contains(range, remoteAddress)); + } + + private static bool Contains(string range, IPAddress candidate) + { + var parts = range.Split('/', 2, StringSplitOptions.TrimEntries); + if (!IPAddress.TryParse(parts[0], out var network)) + return false; + + var candidateAddress = NormalizeFamily(candidate, network.AddressFamily); + if (candidateAddress == null) + return false; + + var networkBytes = network.GetAddressBytes(); + var candidateBytes = candidateAddress.GetAddressBytes(); + var maximumPrefix = networkBytes.Length * 8; + var prefix = maximumPrefix; + if (parts.Length == 2 && + (!int.TryParse(parts[1], out prefix) || prefix < 0 || prefix > maximumPrefix)) + return false; + + var fullBytes = prefix / 8; + var remainingBits = prefix % 8; + for (var index = 0; index < fullBytes; index++) + { + if (networkBytes[index] != candidateBytes[index]) + return false; + } + + if (remainingBits == 0) + return true; + + var mask = (byte)(0xff << (8 - remainingBits)); + return (networkBytes[fullBytes] & mask) == (candidateBytes[fullBytes] & mask); + } + + private static IPAddress NormalizeFamily( + IPAddress address, + System.Net.Sockets.AddressFamily targetFamily) + { + if (address.AddressFamily == targetFamily) + return address; + + if (targetFamily == System.Net.Sockets.AddressFamily.InterNetwork && + address.IsIPv4MappedToIPv6) + return address.MapToIPv4(); + + if (targetFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 && + address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + return address.MapToIPv6(); + + return null; + } + } +} diff --git a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingRateLimiter.cs b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingRateLimiter.cs new file mode 100644 index 000000000..030140a73 --- /dev/null +++ b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingRateLimiter.cs @@ -0,0 +1,122 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Caching.Memory; +using Resgrid.Config; + +namespace Resgrid.Web.Services.ApplicationCore.UnitTracking +{ + public sealed class UnitTrackingRateLimitResult + { + public bool Allowed { get; set; } + public int RetryAfterSeconds { get; set; } + } + + public class UnitTrackingRateLimiter + { + private readonly IMemoryCache _cache; + private readonly object _counterCreationSync = new(); + + public UnitTrackingRateLimiter(IMemoryCache cache) + { + _cache = cache; + } + + public UnitTrackingRateLimitResult CheckUnknownEndpoint(string sourceIp) + { + return Check( + "unknown", + sourceIp ?? "unknown", + 1, + Math.Max(1, UnitTrackingConfig.UnknownEndpointRequestsPerMinute)); + } + + public UnitTrackingRateLimitResult CheckRequest(string deviceId, string credentialId) + { + var maximum = Math.Max(1, UnitTrackingConfig.PerDeviceRequestsPerMinute); + var binding = Check("request-binding", deviceId, 1, maximum); + if (!binding.Allowed) + return binding; + + return Check("request-credential", credentialId, 1, maximum); + } + + public UnitTrackingRateLimitResult CheckRecords( + string deviceId, + string credentialId, + int recordCount) + { + var maximum = Math.Max(1, UnitTrackingConfig.PerDeviceRecordsPerMinute); + var binding = Check("record-binding", deviceId, recordCount, maximum); + if (!binding.Allowed) + return binding; + + return Check("record-credential", credentialId, recordCount, maximum); + } + + private UnitTrackingRateLimitResult Check( + string scope, + string identity, + int amount, + int maximum) + { + if (string.IsNullOrWhiteSpace(identity)) + identity = "unknown"; + + var now = DateTime.UtcNow; + var key = $"UnitTrackingRate:{Digest($"{scope}|{identity}")}"; + WindowCounter counter; + lock (_counterCreationSync) + { + counter = _cache.GetOrCreate( + key, + entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2); + return new WindowCounter(now); + }); + } + + lock (counter.Sync) + { + if (now >= counter.WindowStartedOn.AddMinutes(1)) + { + counter.WindowStartedOn = now; + counter.Count = 0; + } + + var retryAfter = Math.Max( + 1, + (int)Math.Ceiling( + (counter.WindowStartedOn.AddMinutes(1) - now).TotalSeconds)); + if (amount <= 0 || amount > maximum || counter.Count > maximum - amount) + { + return new UnitTrackingRateLimitResult + { + Allowed = false, + RetryAfterSeconds = retryAfter + }; + } + + counter.Count += amount; + return new UnitTrackingRateLimitResult { Allowed = true }; + } + } + + private static string Digest(string value) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))) + .ToLowerInvariant(); + + private sealed class WindowCounter + { + public WindowCounter(DateTime windowStartedOn) + { + WindowStartedOn = windowStartedOn; + } + + public object Sync { get; } = new(); + public DateTime WindowStartedOn { get; set; } + public int Count { get; set; } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitLocationController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitLocationController.cs index 8146494f4..9b4f567db 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitLocationController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitLocationController.cs @@ -44,6 +44,7 @@ public UnitLocationController(IUnitsService unitsService, IUnitLocationEventProv [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Authorize(Policy = ResgridResources.Unit_View)] public async Task> SetUnitLocation(UnitLocationInput locationInput) { @@ -99,7 +100,8 @@ public async Task> SetUnitLocation(UnitLoca if (!String.IsNullOrWhiteSpace(locationInput.Heading) && locationInput.Heading != "NaN" && decimal.TryParse(locationInput.Heading, out var hdn)) location.Heading = hdn; - await _unitLocationEventProvider.EnqueueUnitLocationEventAsync(location); + if (!await _unitLocationEventProvider.EnqueueUnitLocationEventAsync(location)) + return StatusCode(StatusCodes.Status503ServiceUnavailable); result.Id = ""; result.PageSize = 0; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingDevicesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingDevicesController.cs new file mode 100644 index 000000000..97ad39239 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingDevicesController.cs @@ -0,0 +1,599 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Model.Tracking; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Models.v4.UnitTracking; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Administrative lifecycle and status operations for Unit hardware tracking bindings. + /// + [Route("api/v4")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class UnitTrackingDevicesController : V4AuthenticatedApiControllerbase + { + private readonly IUnitTrackingService _unitTrackingService; + private readonly IUnitTrackingCatalogService _catalogService; + private readonly IUnitTrackingStatusService _statusService; + private readonly IUnitTrackingIdentifierService _identifierService; + private readonly Resgrid.Model.Services.IAuthorizationService _authorizationService; + + public UnitTrackingDevicesController( + IUnitTrackingService unitTrackingService, + IUnitTrackingCatalogService catalogService, + IUnitTrackingStatusService statusService, + IUnitTrackingIdentifierService identifierService, + Resgrid.Model.Services.IAuthorizationService authorizationService) + { + _unitTrackingService = unitTrackingService; + _catalogService = catalogService; + _statusService = statusService; + _identifierService = identifierService; + _authorizationService = authorizationService; + } + + [HttpGet("unit-tracking/catalog")] + [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Unit_View)] + public async Task>> GetCatalog( + CancellationToken cancellationToken) + { + var profiles = await _catalogService.GetProfilesAsync(cancellationToken); + return Ok(profiles.Select(MapProfile).ToList()); + } + + [HttpGet("units/{unitId:int}/trackers")] + [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [Authorize(Policy = ResgridResources.Unit_View)] + public async Task>> GetUnitTrackers( + int unitId, + CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserViewUnitAsync(UserId, unitId)) + return Forbid(); + + var canManage = await _authorizationService.CanUserModifyUnitAsync(UserId, unitId); + var devices = await _unitTrackingService.GetDevicesForUnitAsync(DepartmentId, unitId); + var mapped = new List(devices.Count); + foreach (var device in devices) + mapped.Add(await MapDeviceAsync(device, canManage, false, cancellationToken)); + + return Ok(mapped); + } + + [HttpPost("units/{unitId:int}/trackers")] + [ProducesResponseType(typeof(UnitTrackingDeviceData), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task> CreateTracker( + int unitId, + [FromBody] CreateUnitTrackingDeviceInput input, + CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserModifyUnitAsync(UserId, unitId)) + return Forbid(); + if (input == null) + return BadRequest(); + + var profile = await GetSelectableProfileAsync(input.ProfileKey, cancellationToken); + var profileError = ValidateProfileInput(profile, input.DeviceIdentifier); + if (profileError != null) + return BadRequest(new { error = profileError }); + + try + { + var saved = await _unitTrackingService.CreateDeviceAsync( + BuildDevice(unitId, input, profile), + DepartmentId, + UserId, + cancellationToken); + var response = await MapDeviceAsync(saved, true, false, cancellationToken); + return CreatedAtAction(nameof(GetTracker), new { id = saved.UnitTrackingDeviceId }, response); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpGet("unit-trackers/{id}")] + [ProducesResponseType(typeof(UnitTrackingDeviceData), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Unit_View)] + public async Task> GetTracker( + string id, + CancellationToken cancellationToken) + { + var device = await _unitTrackingService.GetDeviceByIdAsync(id, DepartmentId); + if (device == null) + return NotFound(); + if (!await _authorizationService.CanUserViewUnitAsync(UserId, device.UnitId)) + return Forbid(); + + var canManage = await _authorizationService.CanUserModifyUnitAsync(UserId, device.UnitId); + return Ok(await MapDeviceAsync(device, canManage, true, cancellationToken)); + } + + [HttpPut("unit-trackers/{id}")] + [ProducesResponseType(typeof(UnitTrackingDeviceData), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task> UpdateTracker( + string id, + [FromBody] UpdateUnitTrackingDeviceInput input, + CancellationToken cancellationToken) + { + var existing = await _unitTrackingService.GetDeviceByIdAsync(id, DepartmentId); + if (existing == null) + return NotFound(); + if (!await _authorizationService.CanUserModifyUnitAsync(UserId, existing.UnitId)) + return Forbid(); + if (input == null) + return BadRequest(); + + var profile = await GetSelectableProfileAsync(input.ProfileKey, cancellationToken); + var profileError = ValidateProfileInput(profile, input.DeviceIdentifier); + if (profileError != null) + return BadRequest(new { error = profileError }); + + ApplyUpdate(existing, input, profile); + try + { + var saved = await _unitTrackingService.UpdateDeviceAsync( + existing, + DepartmentId, + UserId, + cancellationToken); + return Ok(await MapDeviceAsync(saved, true, true, cancellationToken)); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPost("unit-trackers/{id}/credentials")] + [ProducesResponseType(typeof(UnitTrackingCredentialProvisionData), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task> CreateCredential( + string id, + [FromBody] CreateUnitTrackingCredentialInput input, + CancellationToken cancellationToken) + { + var authorization = await AuthorizeDeviceUpdateAsync(id); + if (authorization.Device == null) + return NotFound(); + if (!authorization.Allowed) + return Forbid(); + if (input == null || + !Enum.IsDefined(typeof(UnitTrackingAuthMode), input.AuthMode) || + input.AuthMode == (int)UnitTrackingAuthMode.Unknown) + return BadRequest(); + + var profile = await _catalogService.GetProfileAsync( + authorization.Device.ModelKey, + cancellationToken); + var authMode = (UnitTrackingAuthMode)input.AuthMode; + if (profile == null || !profile.SupportedAuthModes.Contains(authMode)) + return BadRequest(new { error = "The selected authentication mode is not supported by this profile." }); + + try + { + var provisioned = await _unitTrackingService.CreateCredentialAsync( + id, + DepartmentId, + authMode, + UserId, + input.HeaderName, + input.BasicUsername, + cancellationToken); + SetOneTimeCredentialResponseHeaders(); + var response = BuildProvisioningResponse(provisioned); + return CreatedAtAction(nameof(GetTracker), new { id }, response); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPost("unit-trackers/{id}/credentials/{credentialId}/rotate")] + [ProducesResponseType(typeof(UnitTrackingCredentialProvisionData), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task> RotateCredential( + string id, + string credentialId, + [FromBody] RotateUnitTrackingCredentialInput input, + CancellationToken cancellationToken) + { + var authorization = await AuthorizeDeviceUpdateAsync(id); + if (authorization.Device == null) + return NotFound(); + if (!authorization.Allowed) + return Forbid(); + if (input?.OverlapHours is < 0 or > 168) + return BadRequest(); + + try + { + var provisioned = await _unitTrackingService.RotateCredentialAsync( + id, + credentialId, + DepartmentId, + UserId, + input?.OverlapHours.HasValue == true + ? TimeSpan.FromHours(input.OverlapHours.Value) + : null, + cancellationToken); + SetOneTimeCredentialResponseHeaders(); + var response = BuildProvisioningResponse(provisioned); + return CreatedAtAction(nameof(GetTracker), new { id }, response); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPost("unit-trackers/{id}/credentials/{credentialId}/revoke")] + [ProducesResponseType(typeof(UnitTrackingCredentialData), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task> RevokeCredential( + string id, + string credentialId, + CancellationToken cancellationToken) + { + var authorization = await AuthorizeDeviceUpdateAsync(id); + if (authorization.Device == null) + return NotFound(); + if (!authorization.Allowed) + return Forbid(); + + try + { + var revoked = await _unitTrackingService.RevokeCredentialAsync( + id, + credentialId, + DepartmentId, + UserId, + cancellationToken); + return Ok(MapCredential(revoked)); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + [HttpPost("unit-trackers/{id}/disable")] + [ProducesResponseType(typeof(UnitTrackingDeviceData), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task> DisableTracker( + string id, + CancellationToken cancellationToken) + { + var authorization = await AuthorizeDeviceUpdateAsync(id); + if (authorization.Device == null) + return NotFound(); + if (!authorization.Allowed) + return Forbid(); + + var disabled = await _unitTrackingService.DisableDeviceAsync( + id, + DepartmentId, + UserId, + cancellationToken); + return Ok(await MapDeviceAsync(disabled, true, true, cancellationToken)); + } + + [HttpDelete("unit-trackers/{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task DeleteTracker( + string id, + CancellationToken cancellationToken) + { + var authorization = await AuthorizeDeviceUpdateAsync(id); + if (authorization.Device == null) + return NotFound(); + if (!authorization.Allowed) + return Forbid(); + + await _unitTrackingService.DeleteDeviceAsync( + id, + DepartmentId, + UserId, + cancellationToken); + return NoContent(); + } + + [HttpPost("unit-trackers/{id}/rebind")] + [ProducesResponseType(typeof(UnitTrackingDeviceData), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task> RebindTracker( + string id, + [FromBody] RebindUnitTrackingDeviceInput input, + CancellationToken cancellationToken) + { + var authorization = await AuthorizeDeviceUpdateAsync(id); + if (authorization.Device == null) + return NotFound(); + if (!authorization.Allowed) + return Forbid(); + if (input == null) + return BadRequest(); + if (!await _authorizationService.CanUserModifyUnitAsync(UserId, input.UnitId)) + return Forbid(); + + try + { + var rebound = await _unitTrackingService.RebindDeviceAsync( + id, + DepartmentId, + input.UnitId, + UserId, + cancellationToken); + return Ok(await MapDeviceAsync(rebound, true, false, cancellationToken)); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpGet("unit-trackers/{id}/status")] + [ProducesResponseType(typeof(UnitTrackingDeviceData), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Unit_View)] + public async Task> GetStatus( + string id, + CancellationToken cancellationToken) + { + var device = await _unitTrackingService.GetDeviceByIdAsync(id, DepartmentId); + if (device == null) + return NotFound(); + if (!await _authorizationService.CanUserViewUnitAsync(UserId, device.UnitId)) + return Forbid(); + + var canManage = await _authorizationService.CanUserModifyUnitAsync(UserId, device.UnitId); + return Ok(await MapDeviceAsync(device, canManage, false, cancellationToken)); + } + + private async Task<(UnitTrackingDevice Device, bool Allowed)> AuthorizeDeviceUpdateAsync( + string deviceId) + { + var device = await _unitTrackingService.GetDeviceByIdAsync(deviceId, DepartmentId); + if (device == null) + return (null, false); + + return ( + device, + await _authorizationService.CanUserModifyUnitAsync(UserId, device.UnitId)); + } + + private async Task GetSelectableProfileAsync( + string profileKey, + CancellationToken cancellationToken) + { + var profile = await _catalogService.GetProfileAsync(profileKey, cancellationToken); + return profile?.IsSelectable == true ? profile : null; + } + + private static string ValidateProfileInput( + UnitTrackingCatalogProfile profile, + string deviceIdentifier) + { + if (profile == null) + return "The selected tracking profile was not found."; + if (profile.IdentifierRequired && string.IsNullOrWhiteSpace(deviceIdentifier)) + return "A device identifier is required for the selected tracking profile."; + return null; + } + + private static UnitTrackingDevice BuildDevice( + int unitId, + CreateUnitTrackingDeviceInput input, + UnitTrackingCatalogProfile profile) + { + return new UnitTrackingDevice + { + UnitId = unitId, + DisplayName = input.DisplayName, + ManufacturerKey = profile.ManufacturerKey, + ModelKey = profile.Key, + TransportType = (int)profile.TransportType, + ProtocolKey = profile.ProtocolKey, + PayloadAdapterKey = profile.PayloadAdapterKey, + DeviceIdentifier = input.DeviceIdentifier, + SecondaryIdentifier = input.SecondaryIdentifier, + IsEnabled = true, + SourcePriority = input.SourcePriority, + AllowedSourceCidrs = input.AllowedSourceCidrs, + FirmwareVersion = input.FirmwareVersion + }; + } + + private static void ApplyUpdate( + UnitTrackingDevice device, + UpdateUnitTrackingDeviceInput input, + UnitTrackingCatalogProfile profile) + { + device.DisplayName = input.DisplayName; + device.ManufacturerKey = profile.ManufacturerKey; + device.ModelKey = profile.Key; + device.TransportType = (int)profile.TransportType; + device.ProtocolKey = profile.ProtocolKey; + device.PayloadAdapterKey = profile.PayloadAdapterKey; + device.DeviceIdentifier = input.DeviceIdentifier; + device.SecondaryIdentifier = input.SecondaryIdentifier; + device.IsEnabled = input.IsEnabled; + device.SourcePriority = input.SourcePriority; + device.AllowedSourceCidrs = input.AllowedSourceCidrs; + device.FirmwareVersion = input.FirmwareVersion; + } + + private async Task MapDeviceAsync( + UnitTrackingDevice device, + bool exposeIdentifier, + bool includeCredentials, + CancellationToken cancellationToken) + { + var status = await _statusService.GetEffectiveStatusAsync( + device, + cancellationToken: cancellationToken); + var credentials = includeCredentials + ? await _unitTrackingService.GetCredentialsForDeviceAsync( + device.UnitTrackingDeviceId, + DepartmentId) + : new List(); + + return new UnitTrackingDeviceData + { + UnitTrackingDeviceId = device.UnitTrackingDeviceId, + UnitId = device.UnitId, + DisplayName = device.DisplayName, + ManufacturerKey = device.ManufacturerKey, + ModelKey = device.ModelKey, + TransportType = device.TransportType, + TransportTypeName = Enum.IsDefined(typeof(UnitTrackingTransportType), device.TransportType) + ? ((UnitTrackingTransportType)device.TransportType).ToString() + : UnitTrackingTransportType.Unknown.ToString(), + ProtocolKey = device.ProtocolKey, + PayloadAdapterKey = device.PayloadAdapterKey, + DeviceIdentifier = exposeIdentifier + ? device.DeviceIdentifier + : _identifierService.Mask(device.DeviceIdentifier), + SecondaryIdentifier = exposeIdentifier + ? device.SecondaryIdentifier + : _identifierService.Mask(device.SecondaryIdentifier), + IdentifierMasked = !exposeIdentifier, + IsEnabled = device.IsEnabled, + SourcePriority = device.SourcePriority, + AllowedSourceCidrs = exposeIdentifier ? device.AllowedSourceCidrs : null, + LastSeenOn = device.LastSeenOn, + LastPositionOn = device.LastPositionOn, + LastReceivedOn = device.LastReceivedOn, + Status = (int)status, + StatusName = status.ToString(), + LastErrorCode = device.LastErrorCode, + FirmwareVersion = device.FirmwareVersion, + CreatedOn = device.CreatedOn, + UpdatedOn = device.UpdatedOn, + Credentials = credentials.Select(MapCredential).ToList() + }; + } + + private UnitTrackingCredentialProvisionData BuildProvisioningResponse( + UnitTrackingCredentialProvisionResult provisioned) + { + return new UnitTrackingCredentialProvisionData + { + Credential = MapCredential(provisioned.Credential), + Token = provisioned.Token, + EndpointUrl = provisioned.EndpointUrl, + HeaderName = provisioned.HeaderName, + HeaderValue = provisioned.HeaderValue, + BasicUsername = provisioned.BasicUsername + }; + } + + private void SetOneTimeCredentialResponseHeaders() + { + Response.Headers.CacheControl = "no-store, no-cache, max-age=0"; + Response.Headers.Pragma = "no-cache"; + } + + private static UnitTrackingCatalogProfileData MapProfile( + UnitTrackingCatalogProfile profile) => + new() + { + Key = profile.Key, + ManufacturerKey = profile.ManufacturerKey, + ManufacturerName = profile.ManufacturerName, + Model = profile.Model, + TransportType = (int)profile.TransportType, + TransportTypeName = profile.TransportType.ToString(), + ProtocolKey = profile.ProtocolKey, + PayloadAdapterKey = profile.PayloadAdapterKey, + CertificationStatus = (int)profile.CertificationStatus, + CertificationStatusName = profile.CertificationStatus.ToString(), + IdentifierRequired = profile.IdentifierRequired, + IsSelectable = profile.IsSelectable, + SupportedAuthModes = profile.SupportedAuthModes + .Select(mode => (int)mode) + .ToList(), + SetupSummary = profile.SetupSummary, + RetryExpectation = profile.RetryExpectation + }; + + private static UnitTrackingCredentialData MapCredential( + UnitTrackingCredential credential) => + new() + { + UnitTrackingCredentialId = credential.UnitTrackingCredentialId, + AuthMode = credential.AuthMode, + AuthModeName = Enum.IsDefined(typeof(UnitTrackingAuthMode), credential.AuthMode) + ? ((UnitTrackingAuthMode)credential.AuthMode).ToString() + : UnitTrackingAuthMode.Unknown.ToString(), + HeaderName = credential.HeaderName, + BasicUsername = credential.BasicUsername, + KeyPrefix = credential.KeyPrefix, + ValidFrom = credential.ValidFrom, + ExpiresOn = credential.ExpiresOn, + RevokedOn = credential.RevokedOn, + LastUsedOn = credential.LastUsedOn, + CreatedOn = credential.CreatedOn + }; + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingIngressController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingIngressController.cs new file mode 100644 index 000000000..096a03705 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingIngressController.cs @@ -0,0 +1,241 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model.Tracking; +using Resgrid.Model.Services; +using Resgrid.Web.Services.ApplicationCore.UnitTracking; +using Resgrid.Web.Services.Middleware; +using Resgrid.Web.Services.Models.v4.UnitTracking; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + [ApiController] + [AllowAnonymous] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [Route("api/v4/unit-trackers")] + public class UnitTrackingIngressController : ControllerBase + { + private readonly UnitTrackingHttpAuthenticationService _httpAuthenticationService; + private readonly UnitTrackingJsonPayloadParser _payloadParser; + private readonly UnitTrackingRateLimiter _rateLimiter; + private readonly IUnitTrackingIngressService _ingressService; + + public UnitTrackingIngressController( + UnitTrackingHttpAuthenticationService httpAuthenticationService, + UnitTrackingJsonPayloadParser payloadParser, + UnitTrackingRateLimiter rateLimiter, + IUnitTrackingIngressService ingressService) + { + _httpAuthenticationService = httpAuthenticationService; + _payloadParser = payloadParser; + _rateLimiter = rateLimiter; + _ingressService = ingressService; + } + + [HttpPost("{unitTrackingDeviceId}/positions")] + [ProducesResponseType(typeof(UnitTrackingIngressResponse), StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + [ProducesResponseType(StatusCodes.Status415UnsupportedMediaType)] + [ProducesResponseType(typeof(UnitTrackingIngressErrorResponse), StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(StatusCodes.Status429TooManyRequests)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task PostPositions(string unitTrackingDeviceId) + { + if (!TrackingHttpsEnabled()) + return NotFound(); + + ApplyRequestBodyLimit(); + UnitTrackingHttpAuthenticationResult authentication; + try + { + authentication = await _httpAuthenticationService.AuthenticateEndpointAsync( + Request, + unitTrackingDeviceId, + HttpContext.RequestAborted); + } + catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Unavailable(ex, "Unit tracking endpoint authentication is unavailable."); + } + + if (authentication.Status == UnitTrackingHttpAuthenticationStatus.NotFound) + return UnknownEndpointResponse(); + if (authentication.Status != UnitTrackingHttpAuthenticationStatus.Authenticated) + return Unauthorized(); + + return await AcceptAsync(authentication.Source); + } + + [HttpPost("c/{capabilityToken}")] + [ProducesResponseType(typeof(UnitTrackingIngressResponse), StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + [ProducesResponseType(StatusCodes.Status415UnsupportedMediaType)] + [ProducesResponseType(typeof(UnitTrackingIngressErrorResponse), StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(StatusCodes.Status429TooManyRequests)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task PostCapability(string capabilityToken) + { + if (!TrackingHttpsEnabled()) + return NotFound(); + + ApplyRequestBodyLimit(); + var rawCapabilityToken = + HttpContext.Items[CapabilityPathRedactionMiddleware.CapabilityTokenItemKey] as string; + UnitTrackingHttpAuthenticationResult authentication; + try + { + authentication = await _httpAuthenticationService.AuthenticateCapabilityAsync( + rawCapabilityToken, + HttpContext.RequestAborted); + } + catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Unavailable(ex, "Unit tracking capability authentication is unavailable."); + } + + if (authentication.Status != UnitTrackingHttpAuthenticationStatus.Authenticated) + return UnknownEndpointResponse(); + + return await AcceptAsync(authentication.Source); + } + + private async Task AcceptAsync( + Resgrid.Model.Tracking.AuthenticatedTrackingSource source) + { + if (!UnitTrackingNetworkPolicy.IsAllowed( + HttpContext.Connection.RemoteIpAddress, + source.Device.AllowedSourceCidrs)) + return NotFound(); + + var requestLimit = _rateLimiter.CheckRequest( + source.Device.UnitTrackingDeviceId, + source.Credential.UnitTrackingCredentialId); + if (!requestLimit.Allowed) + return RateLimited(requestLimit); + + if (!_payloadParser.Supports(source.Device.PayloadAdapterKey)) + return Invalid("The configured payload adapter is not supported."); + + var receivedOn = DateTime.UtcNow; + var parsed = await _payloadParser.ParseAsync( + Request, + receivedOn, + HttpContext.RequestAborted); + var parseResponse = MapParseFailure(parsed); + if (parseResponse != null) + return parseResponse; + + var recordLimit = _rateLimiter.CheckRecords( + source.Device.UnitTrackingDeviceId, + source.Credential.UnitTrackingCredentialId, + parsed.Positions.Count); + if (!recordLimit.Allowed) + return RateLimited(recordLimit); + + source.ReportedDeviceIdentifier = parsed.ReportedDeviceIdentifier; + TrackingIngressResult result; + try + { + result = await _ingressService.AcceptAsync( + source, + parsed.Positions, + HttpContext.RequestAborted); + } + catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Unavailable(ex, "Unit tracking ingress is unavailable."); + } + + return result.Status switch + { + TrackingIngressStatus.Accepted => Accepted(new UnitTrackingIngressResponse + { + Accepted = result.Accepted, + DuplicatesPossible = result.DuplicatesPossible, + ReceivedOn = result.ReceivedOn + }), + TrackingIngressStatus.Invalid => UnprocessableEntity(new UnitTrackingIngressErrorResponse + { + Errors = result.Errors?.ToArray() ?? Array.Empty() + }), + _ => StatusCode(StatusCodes.Status503ServiceUnavailable) + }; + } + + private IActionResult MapParseFailure(UnitTrackingPayloadParseResult parsed) + { + return parsed.Status switch + { + UnitTrackingPayloadParseStatus.Success => null, + UnitTrackingPayloadParseStatus.Malformed => BadRequest(), + UnitTrackingPayloadParseStatus.Invalid => UnprocessableEntity( + new UnitTrackingIngressErrorResponse + { + Errors = parsed.Errors?.ToArray() ?? Array.Empty() + }), + UnitTrackingPayloadParseStatus.TooLarge => StatusCode( + StatusCodes.Status413PayloadTooLarge), + _ => StatusCode(StatusCodes.Status415UnsupportedMediaType) + }; + } + + private IActionResult UnknownEndpointResponse() + { + var limit = _rateLimiter.CheckUnknownEndpoint( + HttpContext.Connection.RemoteIpAddress?.ToString()); + return limit.Allowed ? NotFound() : RateLimited(limit); + } + + private IActionResult Invalid(string error) => + UnprocessableEntity(new UnitTrackingIngressErrorResponse + { + Errors = new[] { error } + }); + + private IActionResult RateLimited(UnitTrackingRateLimitResult limit) + { + Response.Headers.RetryAfter = Math.Max(1, limit.RetryAfterSeconds).ToString(); + return StatusCode(StatusCodes.Status429TooManyRequests); + } + + private IActionResult Unavailable(Exception exception, string message) + { + Logging.LogException(exception, message); + return StatusCode(StatusCodes.Status503ServiceUnavailable); + } + + private void ApplyRequestBodyLimit() + { + var feature = HttpContext.Features.Get(); + if (feature != null && !feature.IsReadOnly) + feature.MaxRequestBodySize = Math.Max(1, UnitTrackingConfig.MaxRequestBytes); + } + + private static bool TrackingHttpsEnabled() => + UnitTrackingConfig.Enabled && UnitTrackingConfig.HttpsIngressEnabled; + } +} diff --git a/Web/Resgrid.Web.Services/Middleware/CapabilityPathRedactionMiddleware.cs b/Web/Resgrid.Web.Services/Middleware/CapabilityPathRedactionMiddleware.cs new file mode 100644 index 000000000..9abd50336 --- /dev/null +++ b/Web/Resgrid.Web.Services/Middleware/CapabilityPathRedactionMiddleware.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Resgrid.Web.Services.Middleware +{ + public class CapabilityPathRedactionMiddleware + { + public const string CapabilityTokenItemKey = + "Resgrid.UnitTracking.CapabilityToken"; + public const string RedactedCapabilityPath = + "/api/v4/unit-trackers/c/[REDACTED]"; + private const string CapabilityPathPrefix = + "/api/v4/unit-trackers/c/"; + + private readonly RequestDelegate _next; + + public CapabilityPathRedactionMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path.Value; + if (!string.IsNullOrWhiteSpace(path) && + path.StartsWith(CapabilityPathPrefix, StringComparison.OrdinalIgnoreCase)) + { + var suffix = path.Substring(CapabilityPathPrefix.Length); + var separator = suffix.IndexOf('/'); + var token = separator >= 0 ? suffix.Substring(0, separator) : suffix; + if (!string.IsNullOrWhiteSpace(token)) + { + context.Items[CapabilityTokenItemKey] = token; + context.Request.Path = + RedactedCapabilityPath + + (separator >= 0 ? suffix.Substring(separator) : string.Empty); + } + } + + await _next(context); + } + + public static string RedactCapabilityUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + var start = url.IndexOf(CapabilityPathPrefix, StringComparison.OrdinalIgnoreCase); + if (start < 0) + return url; + + var tokenStart = start + CapabilityPathPrefix.Length; + var tokenEnd = url.IndexOfAny(new[] { '?', '#', '/' }, tokenStart); + if (tokenEnd < 0) + tokenEnd = url.Length; + + return url.Substring(0, tokenStart) + + "[REDACTED]" + + url.Substring(tokenEnd); + } + } +} diff --git a/Web/Resgrid.Web.Services/Middleware/SentryEventProcessor.cs b/Web/Resgrid.Web.Services/Middleware/SentryEventProcessor.cs index 122fb21d5..5e9910e30 100644 --- a/Web/Resgrid.Web.Services/Middleware/SentryEventProcessor.cs +++ b/Web/Resgrid.Web.Services/Middleware/SentryEventProcessor.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Middleware; using Resgrid.Web.ServicesCore.Helpers; using Sentry; using Sentry.Extensibility; @@ -16,6 +17,9 @@ public class SentryEventProcessor : ISentryEventProcessor public SentryEvent Process(SentryEvent @event) { @event.SetExtra("Response:HasStarted", _httpContext.HttpContext?.Response.HasStarted); + if (@event.Request != null) + @event.Request.Url = + CapabilityPathRedactionMiddleware.RedactCapabilityUrl(@event.Request.Url); try { diff --git a/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingAdminModels.cs b/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingAdminModels.cs new file mode 100644 index 000000000..149c042b3 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingAdminModels.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Resgrid.Web.Services.Models.v4.UnitTracking +{ + public sealed class UnitTrackingCatalogProfileData + { + public string Key { get; set; } + public string ManufacturerKey { get; set; } + public string ManufacturerName { get; set; } + public string Model { get; set; } + public int TransportType { get; set; } + public string TransportTypeName { get; set; } + public string ProtocolKey { get; set; } + public string PayloadAdapterKey { get; set; } + public int CertificationStatus { get; set; } + public string CertificationStatusName { get; set; } + public bool IdentifierRequired { get; set; } + public bool IsSelectable { get; set; } + public IReadOnlyCollection SupportedAuthModes { get; set; } = Array.Empty(); + public string SetupSummary { get; set; } + public string RetryExpectation { get; set; } + } + + public sealed class UnitTrackingDeviceData + { + public string UnitTrackingDeviceId { get; set; } + public int UnitId { get; set; } + public string DisplayName { get; set; } + public string ManufacturerKey { get; set; } + public string ModelKey { get; set; } + public int TransportType { get; set; } + public string TransportTypeName { get; set; } + public string ProtocolKey { get; set; } + public string PayloadAdapterKey { get; set; } + public string DeviceIdentifier { get; set; } + public string SecondaryIdentifier { get; set; } + public bool IdentifierMasked { get; set; } + public bool IsEnabled { get; set; } + public int SourcePriority { get; set; } + public string AllowedSourceCidrs { get; set; } + public DateTime? LastSeenOn { get; set; } + public DateTime? LastPositionOn { get; set; } + public DateTime? LastReceivedOn { get; set; } + public int Status { get; set; } + public string StatusName { get; set; } + public string LastErrorCode { get; set; } + public string FirmwareVersion { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime? UpdatedOn { get; set; } + public IReadOnlyCollection Credentials { get; set; } = + Array.Empty(); + } + + public sealed class UnitTrackingCredentialData + { + public string UnitTrackingCredentialId { get; set; } + public int AuthMode { get; set; } + public string AuthModeName { get; set; } + public string HeaderName { get; set; } + public string BasicUsername { get; set; } + public string KeyPrefix { get; set; } + public DateTime ValidFrom { get; set; } + public DateTime? ExpiresOn { get; set; } + public DateTime? RevokedOn { get; set; } + public DateTime? LastUsedOn { get; set; } + public DateTime CreatedOn { get; set; } + } + + public sealed class UnitTrackingCredentialProvisionData + { + public UnitTrackingCredentialData Credential { get; set; } + public string Token { get; set; } + public string EndpointUrl { get; set; } + public string HeaderName { get; set; } + public string HeaderValue { get; set; } + public string BasicUsername { get; set; } + } + + public sealed class CreateUnitTrackingDeviceInput + { + [Required] + [MaxLength(64)] + public string ProfileKey { get; set; } + + [MaxLength(200)] + public string DisplayName { get; set; } + + [MaxLength(128)] + public string DeviceIdentifier { get; set; } + + [MaxLength(128)] + public string SecondaryIdentifier { get; set; } + + public int SourcePriority { get; set; } = 100; + + [MaxLength(2048)] + public string AllowedSourceCidrs { get; set; } + + [MaxLength(128)] + public string FirmwareVersion { get; set; } + } + + public sealed class UpdateUnitTrackingDeviceInput + { + [Required] + [MaxLength(64)] + public string ProfileKey { get; set; } + + [MaxLength(200)] + public string DisplayName { get; set; } + + [MaxLength(128)] + public string DeviceIdentifier { get; set; } + + [MaxLength(128)] + public string SecondaryIdentifier { get; set; } + + public bool IsEnabled { get; set; } = true; + public int SourcePriority { get; set; } = 100; + + [MaxLength(2048)] + public string AllowedSourceCidrs { get; set; } + + [MaxLength(128)] + public string FirmwareVersion { get; set; } + } + + public sealed class CreateUnitTrackingCredentialInput + { + [Range(1, 4)] + public int AuthMode { get; set; } + + [MaxLength(128)] + public string HeaderName { get; set; } + + [MaxLength(128)] + public string BasicUsername { get; set; } + } + + public sealed class RotateUnitTrackingCredentialInput + { + [Range(0, 168)] + public int? OverlapHours { get; set; } + } + + public sealed class RebindUnitTrackingDeviceInput + { + [Range(1, int.MaxValue)] + public int UnitId { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingIngressResponse.cs b/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingIngressResponse.cs new file mode 100644 index 000000000..fa237880b --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingIngressResponse.cs @@ -0,0 +1,16 @@ +using System; + +namespace Resgrid.Web.Services.Models.v4.UnitTracking +{ + public sealed class UnitTrackingIngressResponse + { + public int Accepted { get; set; } + public bool DuplicatesPossible { get; set; } + public DateTime ReceivedOn { get; set; } + } + + public sealed class UnitTrackingIngressErrorResponse + { + public string[] Errors { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingPositionInput.cs b/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingPositionInput.cs new file mode 100644 index 000000000..813962f99 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingPositionInput.cs @@ -0,0 +1,71 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Web.Services.Models.v4.UnitTracking +{ + public sealed class UnitTrackingPositionInput + { + [JsonProperty("eventId")] + public string EventId { get; set; } + + [JsonProperty("timestamp")] + public JToken Timestamp { get; set; } + + [JsonProperty("latitude")] + public decimal? Latitude { get; set; } + + [JsonProperty("longitude")] + public decimal? Longitude { get; set; } + + [JsonProperty("accuracyMeters")] + public decimal? AccuracyMeters { get; set; } + + [JsonProperty("altitudeMeters")] + public decimal? AltitudeMeters { get; set; } + + [JsonProperty("speedMetersPerSecond")] + public decimal? SpeedMetersPerSecond { get; set; } + + [JsonProperty("headingDegrees")] + public decimal? HeadingDegrees { get; set; } + + [JsonProperty("satellites")] + public int? Satellites { get; set; } + + [JsonProperty("hdop")] + public decimal? Hdop { get; set; } + + [JsonProperty("batteryPercent")] + public decimal? BatteryPercent { get; set; } + + [JsonProperty("externalPowerVolts")] + public decimal? ExternalPowerVolts { get; set; } + + [JsonProperty("signalPercent")] + public int? SignalPercent { get; set; } + + [JsonProperty("ignition")] + public bool? Ignition { get; set; } + + [JsonProperty("moving")] + public bool? Moving { get; set; } + + [JsonProperty("alarmCode")] + public string AlarmCode { get; set; } + + [JsonProperty("validFix")] + public bool? ValidFix { get; set; } + + [JsonProperty("deviceIdentifier")] + public string DeviceIdentifier { get; set; } + } + + public sealed class UnitTrackingPositionsInput + { + [JsonProperty("deviceIdentifier")] + public string DeviceIdentifier { get; set; } + + [JsonProperty("positions")] + public UnitTrackingPositionInput[] Positions { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Program.cs b/Web/Resgrid.Web.Services/Program.cs index a15612837..e4f9b80a7 100644 --- a/Web/Resgrid.Web.Services/Program.cs +++ b/Web/Resgrid.Web.Services/Program.cs @@ -28,6 +28,11 @@ public static IHostBuilder CreateHostBuilder(string[] args) => { logging.ClearProviders(); logging.AddConsole(); + // Hosting request-start logs run before application middleware and would + // otherwise record capability credentials embedded in tracking URLs. + logging.AddFilter( + "Microsoft.AspNetCore.Hosting.Diagnostics", + LogLevel.Warning); }) .ConfigureWebHostDefaults(webBuilder => { diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index c8af7269c..480c29add 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -55,6 +55,7 @@ using Resgrid.Providers.Messaging; using Resgrid.Web.Services; using Resgrid.Web.Services.Twilio; +using Resgrid.Web.Services.ApplicationCore.UnitTracking; using Twilio.AspNet.Core; namespace Resgrid.Web.ServicesCore @@ -651,6 +652,9 @@ public void ConfigureContainer(ContainerBuilder builder) builder.RegisterType().As>().InstancePerLifetimeScope(); builder.RegisterType().As>().InstancePerLifetimeScope(); builder.RegisterType>().As>().InstancePerLifetimeScope(); + builder.RegisterType().AsSelf().InstancePerLifetimeScope(); + builder.RegisterType().AsSelf().InstancePerLifetimeScope(); + builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType>().As>().InstancePerLifetimeScope(); builder.RegisterType>().As>().InstancePerLifetimeScope(); @@ -669,6 +673,7 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerF ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor }; app.UseForwardedHeaders(forwardOpts); + app.UseMiddleware(); //loggerFactory.AddConsole(Configuration.GetSection("Logging")); //loggerFactory.AddDebug(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/UnitTrackingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/UnitTrackingController.cs new file mode 100644 index 000000000..c86538dd3 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/UnitTrackingController.cs @@ -0,0 +1,649 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Model.Tracking; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.UnitTracking; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + [Area("User")] + public class UnitTrackingController : SecureBaseController + { + private readonly IUnitTrackingService _unitTrackingService; + private readonly IUnitTrackingCatalogService _catalogService; + private readonly IUnitTrackingStatusService _statusService; + private readonly IUnitTrackingIdentifierService _identifierService; + private readonly IUnitsService _unitsService; + private readonly Resgrid.Model.Services.IAuthorizationService _authorizationService; + private readonly IStringLocalizer _localizer; + + public UnitTrackingController( + IUnitTrackingService unitTrackingService, + IUnitTrackingCatalogService catalogService, + IUnitTrackingStatusService statusService, + IUnitTrackingIdentifierService identifierService, + IUnitsService unitsService, + Resgrid.Model.Services.IAuthorizationService authorizationService, + IStringLocalizer localizer) + { + _unitTrackingService = unitTrackingService; + _catalogService = catalogService; + _statusService = statusService; + _identifierService = identifierService; + _unitsService = unitsService; + _authorizationService = authorizationService; + _localizer = localizer; + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Unit_View)] + public async Task Index(int unitId, CancellationToken cancellationToken) + { + var unit = await GetOwnedUnitAsync(unitId); + if (unit == null) + return Unauthorized(); + if (!await _authorizationService.CanUserViewUnitAsync(UserId, unitId)) + return Unauthorized(); + + var canManage = await _authorizationService.CanUserModifyUnitAsync(UserId, unitId); + var devices = await _unitTrackingService.GetDevicesForUnitAsync(DepartmentId, unitId); + return View(new UnitTrackingIndexView + { + Unit = unit, + CanManage = canManage, + Devices = await MapDevicesAsync(devices, canManage, cancellationToken) + }); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task New(int unitId, CancellationToken cancellationToken) + { + var unit = await GetOwnedUnitAsync(unitId); + if (unit == null || + !await _authorizationService.CanUserModifyUnitAsync(UserId, unitId)) + return Unauthorized(); + + return View(await BuildEditorAsync( + unit, + new UnitTrackingEditorView + { + Unit = unit, + IsEnabled = true, + SourcePriority = 100 + }, + cancellationToken)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task New( + int unitId, + UnitTrackingEditorView model, + CancellationToken cancellationToken) + { + var unit = await GetOwnedUnitAsync(unitId); + if (unit == null || + !await _authorizationService.CanUserModifyUnitAsync(UserId, unitId)) + return Unauthorized(); + + var profile = await _catalogService.GetProfileAsync(model.ProfileKey, cancellationToken); + ValidateProfile(profile, model.DeviceIdentifier); + if (!ModelState.IsValid) + return View(await BuildEditorAsync(unit, model, cancellationToken)); + + try + { + var saved = await _unitTrackingService.CreateDeviceAsync( + BuildDevice(unitId, model, profile), + DepartmentId, + UserId, + cancellationToken); + TempData["UnitTrackingSuccess"] = _localizer["TrackingBindingCreatedMessage"].Value; + return RedirectToAction(nameof(Details), new { id = saved.UnitTrackingDeviceId }); + } + catch (ArgumentException ex) + { + ModelState.AddModelError(string.Empty, ex.Message); + return View(await BuildEditorAsync(unit, model, cancellationToken)); + } + catch (InvalidOperationException ex) + { + ModelState.AddModelError(string.Empty, ex.Message); + return View(await BuildEditorAsync(unit, model, cancellationToken)); + } + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Unit_View)] + public async Task Details(string id, CancellationToken cancellationToken) + { + var context = await GetAuthorizedDeviceAsync(id, false); + if (context.Device == null || !context.Allowed) + return Unauthorized(); + + var credentials = await _unitTrackingService.GetCredentialsForDeviceAsync( + id, + DepartmentId); + var profile = await _catalogService.GetProfileAsync( + context.Device.ModelKey, + cancellationToken); + return View(new UnitTrackingDetailsView + { + Unit = context.Unit, + Device = context.Device, + Profile = profile, + Status = await _statusService.GetEffectiveStatusAsync( + context.Device, + cancellationToken: cancellationToken), + DisplayIdentifier = context.CanManage + ? context.Device.DeviceIdentifier + : _identifierService.Mask(context.Device.DeviceIdentifier), + CanManage = context.CanManage, + CanPreviewJson = + SystemBehaviorConfig.Environment != SystemEnvironment.Prod && + ClaimsAuthorizationHelper.IsUserDepartmentAdmin(), + SupportedAuthModes = profile?.SupportedAuthModes ?? + Array.Empty(), + Credentials = credentials, + CredentialInput = new CreateUnitTrackingCredentialView + { + UnitTrackingDeviceId = id, + AuthMode = (int)(profile?.SupportedAuthModes.FirstOrDefault() ?? + UnitTrackingAuthMode.Bearer) + } + }); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task Edit(string id, CancellationToken cancellationToken) + { + var context = await GetAuthorizedDeviceAsync(id, true); + if (context.Device == null || !context.Allowed) + return Unauthorized(); + + return View(await BuildEditorAsync( + context.Unit, + new UnitTrackingEditorView + { + Unit = context.Unit, + UnitTrackingDeviceId = id, + IsEdit = true, + ProfileKey = context.Device.ModelKey, + DisplayName = context.Device.DisplayName, + DeviceIdentifier = context.Device.DeviceIdentifier, + SecondaryIdentifier = context.Device.SecondaryIdentifier, + IsEnabled = context.Device.IsEnabled, + SourcePriority = context.Device.SourcePriority, + AllowedSourceCidrs = context.Device.AllowedSourceCidrs, + FirmwareVersion = context.Device.FirmwareVersion + }, + cancellationToken)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task Edit( + string id, + UnitTrackingEditorView model, + CancellationToken cancellationToken) + { + var context = await GetAuthorizedDeviceAsync(id, true); + if (context.Device == null || !context.Allowed) + return Unauthorized(); + + var profile = await _catalogService.GetProfileAsync(model.ProfileKey, cancellationToken); + ValidateProfile(profile, model.DeviceIdentifier); + if (!ModelState.IsValid) + return View(await BuildEditorAsync(context.Unit, model, cancellationToken)); + + ApplyUpdate(context.Device, model, profile); + try + { + await _unitTrackingService.UpdateDeviceAsync( + context.Device, + DepartmentId, + UserId, + cancellationToken); + TempData["UnitTrackingSuccess"] = _localizer["TrackingBindingUpdatedMessage"].Value; + return RedirectToAction(nameof(Details), new { id }); + } + catch (ArgumentException ex) + { + ModelState.AddModelError(string.Empty, ex.Message); + return View(await BuildEditorAsync(context.Unit, model, cancellationToken)); + } + catch (InvalidOperationException ex) + { + ModelState.AddModelError(string.Empty, ex.Message); + return View(await BuildEditorAsync(context.Unit, model, cancellationToken)); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task CreateCredential( + CreateUnitTrackingCredentialView model, + CancellationToken cancellationToken) + { + var context = await GetAuthorizedDeviceAsync(model.UnitTrackingDeviceId, true); + if (context.Device == null || !context.Allowed) + return Unauthorized(); + + var profile = await _catalogService.GetProfileAsync( + context.Device.ModelKey, + cancellationToken); + var authMode = (UnitTrackingAuthMode)model.AuthMode; + if (profile == null || !profile.SupportedAuthModes.Contains(authMode)) + ModelState.AddModelError( + nameof(model.AuthMode), + _localizer["UnsupportedTrackingAuth"].Value); + if (authMode == UnitTrackingAuthMode.CustomHeader && + string.IsNullOrWhiteSpace(model.HeaderName)) + ModelState.AddModelError( + nameof(model.HeaderName), + _localizer["CustomHeaderRequired"].Value); + if (authMode == UnitTrackingAuthMode.Basic && + string.IsNullOrWhiteSpace(model.BasicUsername)) + ModelState.AddModelError( + nameof(model.BasicUsername), + _localizer["BasicUsernameRequired"].Value); + + if (!ModelState.IsValid) + { + TempData["UnitTrackingError"] = + string.Join(" ", ModelState.Values.SelectMany(value => value.Errors) + .Select(error => error.ErrorMessage)); + return RedirectToAction(nameof(Details), new { id = model.UnitTrackingDeviceId }); + } + + try + { + var provisioned = await _unitTrackingService.CreateCredentialAsync( + context.Device.UnitTrackingDeviceId, + DepartmentId, + authMode, + UserId, + model.HeaderName, + model.BasicUsername, + cancellationToken); + return OneTimeCredential(context.Unit, context.Device, profile, provisioned); + } + catch (ArgumentException ex) + { + TempData["UnitTrackingError"] = ex.Message; + return RedirectToAction(nameof(Details), new { id = model.UnitTrackingDeviceId }); + } + catch (InvalidOperationException ex) + { + TempData["UnitTrackingError"] = ex.Message; + return RedirectToAction(nameof(Details), new { id = model.UnitTrackingDeviceId }); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task RotateCredential( + RotateUnitTrackingCredentialView model, + CancellationToken cancellationToken) + { + var context = await GetAuthorizedDeviceAsync(model.UnitTrackingDeviceId, true); + if (context.Device == null || !context.Allowed) + return Unauthorized(); + if (!ModelState.IsValid) + return RedirectToAction(nameof(Details), new { id = model.UnitTrackingDeviceId }); + + try + { + var provisioned = await _unitTrackingService.RotateCredentialAsync( + context.Device.UnitTrackingDeviceId, + model.UnitTrackingCredentialId, + DepartmentId, + UserId, + TimeSpan.FromHours(model.OverlapHours), + cancellationToken); + var profile = await _catalogService.GetProfileAsync( + context.Device.ModelKey, + cancellationToken); + return OneTimeCredential(context.Unit, context.Device, profile, provisioned); + } + catch (InvalidOperationException ex) + { + TempData["UnitTrackingError"] = ex.Message; + return RedirectToAction(nameof(Details), new { id = model.UnitTrackingDeviceId }); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task RevokeCredential( + string id, + string credentialId, + CancellationToken cancellationToken) + { + var context = await GetAuthorizedDeviceAsync(id, true); + if (context.Device == null || !context.Allowed) + return Unauthorized(); + + await _unitTrackingService.RevokeCredentialAsync( + id, + credentialId, + DepartmentId, + UserId, + cancellationToken); + TempData["UnitTrackingSuccess"] = _localizer["TrackingCredentialRevokedMessage"].Value; + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task Disable( + string id, + CancellationToken cancellationToken) + { + var context = await GetAuthorizedDeviceAsync(id, true); + if (context.Device == null || !context.Allowed) + return Unauthorized(); + + await _unitTrackingService.DisableDeviceAsync( + id, + DepartmentId, + UserId, + cancellationToken); + TempData["UnitTrackingSuccess"] = _localizer["TrackingBindingDisabledMessage"].Value; + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task Delete( + string id, + CancellationToken cancellationToken) + { + var context = await GetAuthorizedDeviceAsync(id, true); + if (context.Device == null || !context.Allowed) + return Unauthorized(); + + await _unitTrackingService.DeleteDeviceAsync( + id, + DepartmentId, + UserId, + cancellationToken); + TempData["UnitTrackingSuccess"] = _localizer["TrackingBindingDeletedMessage"].Value; + return RedirectToAction(nameof(Index), new { unitId = context.Device.UnitId }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task PreviewJson( + PreviewUnitTrackingJsonView model, + CancellationToken cancellationToken) + { + if (SystemBehaviorConfig.Environment == SystemEnvironment.Prod || + !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return NotFound(); + + if (model == null || string.IsNullOrWhiteSpace(model.UnitTrackingDeviceId)) + return BadRequest(); + + var context = await GetAuthorizedDeviceAsync(model.UnitTrackingDeviceId, true); + if (context.Device == null || !context.Allowed) + return Unauthorized(); + + var preview = ValidatePreviewJson(model.JsonPayload); + TempData["UnitTrackingPreview"] = preview.IsValid + ? _localizer["PreviewJsonSuccess", preview.PositionCount].Value + : _localizer[preview.ErrorKey].Value; + return RedirectToAction( + nameof(Details), + new { id = model.UnitTrackingDeviceId }); + } + + private IActionResult OneTimeCredential( + Unit unit, + UnitTrackingDevice device, + UnitTrackingCatalogProfile profile, + UnitTrackingCredentialProvisionResult provisioned) + { + Response.Headers.CacheControl = "no-store, no-cache, max-age=0"; + Response.Headers.Pragma = "no-cache"; + return View("Credential", new UnitTrackingCredentialDisplayView + { + Unit = unit, + Device = device, + Profile = profile, + Provisioning = provisioned + }); + } + + private async Task GetOwnedUnitAsync(int unitId) + { + var unit = await _unitsService.GetUnitByIdAsync(unitId); + return unit?.DepartmentId == DepartmentId ? unit : null; + } + + private async Task<(UnitTrackingDevice Device, Unit Unit, bool Allowed, bool CanManage)> + GetAuthorizedDeviceAsync(string deviceId, bool requireManage) + { + var device = await _unitTrackingService.GetDeviceByIdAsync(deviceId, DepartmentId); + if (device == null) + return (null, null, false, false); + + var unit = await GetOwnedUnitAsync(device.UnitId); + if (unit == null) + return (null, null, false, false); + + var canManage = await _authorizationService.CanUserModifyUnitAsync(UserId, device.UnitId); + var allowed = requireManage + ? canManage + : await _authorizationService.CanUserViewUnitAsync(UserId, device.UnitId); + return (device, unit, allowed, canManage); + } + + private async Task> MapDevicesAsync( + IReadOnlyCollection devices, + bool exposeIdentifier, + CancellationToken cancellationToken) + { + var mapped = new List(devices.Count); + foreach (var device in devices) + { + mapped.Add(new UnitTrackingDeviceStatusView + { + Device = device, + Status = await _statusService.GetEffectiveStatusAsync( + device, + cancellationToken: cancellationToken), + DisplayIdentifier = exposeIdentifier + ? device.DeviceIdentifier + : _identifierService.Mask(device.DeviceIdentifier) + }); + } + + return mapped; + } + + private static (bool IsValid, int PositionCount, string ErrorKey) ValidatePreviewJson( + string json) + { + if (string.IsNullOrWhiteSpace(json)) + return (false, 0, "PreviewJsonRequired"); + if (Encoding.UTF8.GetByteCount(json) > Math.Max(1, UnitTrackingConfig.MaxRequestBytes)) + return (false, 0, "PreviewJsonTooLarge"); + + JObject root; + try + { + using var stringReader = new StringReader(json); + using var jsonReader = new JsonTextReader(stringReader) + { + DateParseHandling = DateParseHandling.None, + FloatParseHandling = FloatParseHandling.Decimal, + MaxDepth = Math.Max(1, UnitTrackingConfig.MaxJsonDepth) + }; + var token = JToken.ReadFrom( + jsonReader, + new JsonLoadSettings + { + DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error, + LineInfoHandling = LineInfoHandling.Ignore + }); + if (token is not JObject parsedRoot || HasTrailingJsonContent(jsonReader)) + return (false, 0, "PreviewJsonMalformed"); + root = parsedRoot; + } + catch (Exception ex) when (ex is JsonException || ex is ArgumentException) + { + return (false, 0, "PreviewJsonMalformed"); + } + + IReadOnlyCollection positions; + if (root.TryGetValue( + "positions", + StringComparison.OrdinalIgnoreCase, + out var positionsToken)) + { + if (positionsToken is not JArray positionArray || + positionArray.Count == 0 || + positionArray.Any(item => item is not JObject)) + return (false, 0, "PreviewJsonPositionsRequired"); + if (positionArray.Count > Math.Max(1, UnitTrackingConfig.MaxBatchPositions)) + return (false, 0, "PreviewJsonTooManyPositions"); + positions = positionArray.Cast().ToList(); + } + else + { + positions = new[] { root }; + } + + foreach (var position in positions) + { + if (!TryGetNonEmptyString(position, "eventId") || + !TryGetCoordinate(position, "latitude", -90m, 90m) || + !TryGetCoordinate(position, "longitude", -180m, 180m)) + return (false, 0, "PreviewJsonPositionInvalid"); + } + + return (true, positions.Count, null); + } + + private static bool HasTrailingJsonContent(JsonTextReader reader) + { + while (reader.Read()) + { + if (reader.TokenType != JsonToken.Comment) + return true; + } + + return false; + } + + private static bool TryGetNonEmptyString(JObject value, string propertyName) => + value.TryGetValue(propertyName, StringComparison.OrdinalIgnoreCase, out var token) && + token.Type == JTokenType.String && + !string.IsNullOrWhiteSpace(token.Value()); + + private static bool TryGetCoordinate( + JObject value, + string propertyName, + decimal minimum, + decimal maximum) + { + if (!value.TryGetValue(propertyName, StringComparison.OrdinalIgnoreCase, out var token) || + token.Type is not (JTokenType.Integer or JTokenType.Float)) + return false; + + var coordinate = token.Value(); + return coordinate >= minimum && coordinate <= maximum; + } + + private async Task BuildEditorAsync( + Unit unit, + UnitTrackingEditorView model, + CancellationToken cancellationToken) + { + model.Unit = unit; + model.Profiles = (await _catalogService.GetProfilesAsync(cancellationToken)) + .Where(profile => profile.IsSelectable) + .ToList(); + return model; + } + + private void ValidateProfile( + UnitTrackingCatalogProfile profile, + string deviceIdentifier) + { + if (profile?.IsSelectable != true) + ModelState.AddModelError( + nameof(UnitTrackingEditorView.ProfileKey), + _localizer["SelectValidTrackingProfile"].Value); + else if (profile.IdentifierRequired && string.IsNullOrWhiteSpace(deviceIdentifier)) + ModelState.AddModelError( + nameof(UnitTrackingEditorView.DeviceIdentifier), + _localizer["TrackingIdentifierRequired"].Value); + } + + private static UnitTrackingDevice BuildDevice( + int unitId, + UnitTrackingEditorView model, + UnitTrackingCatalogProfile profile) => + new() + { + UnitId = unitId, + DisplayName = model.DisplayName, + ManufacturerKey = profile.ManufacturerKey, + ModelKey = profile.Key, + TransportType = (int)profile.TransportType, + ProtocolKey = profile.ProtocolKey, + PayloadAdapterKey = profile.PayloadAdapterKey, + DeviceIdentifier = model.DeviceIdentifier, + SecondaryIdentifier = model.SecondaryIdentifier, + IsEnabled = true, + SourcePriority = model.SourcePriority, + AllowedSourceCidrs = model.AllowedSourceCidrs, + FirmwareVersion = model.FirmwareVersion + }; + + private static void ApplyUpdate( + UnitTrackingDevice device, + UnitTrackingEditorView model, + UnitTrackingCatalogProfile profile) + { + device.DisplayName = model.DisplayName; + device.ManufacturerKey = profile.ManufacturerKey; + device.ModelKey = profile.Key; + device.TransportType = (int)profile.TransportType; + device.ProtocolKey = profile.ProtocolKey; + device.PayloadAdapterKey = profile.PayloadAdapterKey; + device.DeviceIdentifier = model.DeviceIdentifier; + device.SecondaryIdentifier = model.SecondaryIdentifier; + device.IsEnabled = model.IsEnabled; + device.SourcePriority = model.SourcePriority; + device.AllowedSourceCidrs = model.AllowedSourceCidrs; + device.FirmwareVersion = model.FirmwareVersion; + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/UnitTracking/UnitTrackingViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/UnitTracking/UnitTrackingViewModels.cs new file mode 100644 index 000000000..c9862a724 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/UnitTracking/UnitTrackingViewModels.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Resgrid.Model; +using Resgrid.Model.Tracking; + +namespace Resgrid.Web.Areas.User.Models.UnitTracking +{ + public sealed class UnitTrackingIndexView + { + public Unit Unit { get; set; } + public bool CanManage { get; set; } + public IReadOnlyCollection Devices { get; set; } = + Array.Empty(); + } + + public sealed class UnitTrackingDeviceStatusView + { + public UnitTrackingDevice Device { get; set; } + public UnitTrackingDeviceStatus Status { get; set; } + public string DisplayIdentifier { get; set; } + public IReadOnlyCollection Credentials { get; set; } = + Array.Empty(); + } + + public sealed class UnitTrackingEditorView + { + public Unit Unit { get; set; } + public string UnitTrackingDeviceId { get; set; } + public bool IsEdit { get; set; } + public IReadOnlyCollection Profiles { get; set; } = + Array.Empty(); + + [Required] + [MaxLength(64)] + public string ProfileKey { get; set; } + + [MaxLength(200)] + public string DisplayName { get; set; } + + [MaxLength(128)] + public string DeviceIdentifier { get; set; } + + [MaxLength(128)] + public string SecondaryIdentifier { get; set; } + + public bool IsEnabled { get; set; } = true; + public int SourcePriority { get; set; } = 100; + + [MaxLength(2048)] + public string AllowedSourceCidrs { get; set; } + + [MaxLength(128)] + public string FirmwareVersion { get; set; } + } + + public sealed class UnitTrackingDetailsView + { + public Unit Unit { get; set; } + public UnitTrackingDevice Device { get; set; } + public UnitTrackingCatalogProfile Profile { get; set; } + public UnitTrackingDeviceStatus Status { get; set; } + public string DisplayIdentifier { get; set; } + public bool CanManage { get; set; } + public bool CanPreviewJson { get; set; } + public IReadOnlyCollection SupportedAuthModes { get; set; } = + Array.Empty(); + public IReadOnlyCollection Credentials { get; set; } = + Array.Empty(); + public CreateUnitTrackingCredentialView CredentialInput { get; set; } = + new(); + } + + public sealed class CreateUnitTrackingCredentialView + { + [Required] + public string UnitTrackingDeviceId { get; set; } + + [Range(1, 4)] + public int AuthMode { get; set; } = (int)UnitTrackingAuthMode.Bearer; + + [MaxLength(128)] + public string HeaderName { get; set; } + + [MaxLength(128)] + public string BasicUsername { get; set; } + } + + public sealed class RotateUnitTrackingCredentialView + { + [Required] + public string UnitTrackingDeviceId { get; set; } + + [Required] + public string UnitTrackingCredentialId { get; set; } + + [Range(0, 168)] + public int OverlapHours { get; set; } = 24; + } + + public sealed class UnitTrackingCredentialDisplayView + { + public Unit Unit { get; set; } + public UnitTrackingDevice Device { get; set; } + public UnitTrackingCatalogProfile Profile { get; set; } + public UnitTrackingCredentialProvisionResult Provisioning { get; set; } + } + + public sealed class PreviewUnitTrackingJsonView + { + [Required] + public string UnitTrackingDeviceId { get; set; } + + [Required] + public string JsonPayload { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Credential.cshtml b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Credential.cshtml new file mode 100644 index 000000000..5c178fb14 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Credential.cshtml @@ -0,0 +1,91 @@ +@model Resgrid.Web.Areas.User.Models.UnitTracking.UnitTrackingCredentialDisplayView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["OneTimeCredentialHeader"]; +} + +
+
+

@localizer["OneTimeCredentialHeader"]

+ +
+
+ +
+
+ @localizer["OneTimeCredentialWarning"] +
+
+
@(Model.Device.DisplayName ?? Model.Device.UnitTrackingDeviceId)
+
+
+ @localizer["SetupInstructions"]
+ @(Model.Profile?.SetupSummary ?? Model.Device.PayloadAdapterKey)
+ @localizer["RetryExpectation"] + @(Model.Profile?.RetryExpectation ?? "—") +
+
+
@localizer["TrackingProfile"]
+
@(Model.Profile == null ? Model.Device.ModelKey : $"{Model.Profile.ManufacturerName} — {Model.Profile.Model}")
+
@localizer["Protocol"]
+
@(Model.Device.ProtocolKey ?? "—")
+
@localizer["Transport"]
+
@((Resgrid.Model.UnitTrackingTransportType)Model.Device.TransportType)
+
+
+ +
+ + +
+
+ @if (!string.IsNullOrWhiteSpace(Model.Provisioning.HeaderName)) + { +
+ + +
+
+ +
+ + +
+
+ } + @if (!string.IsNullOrWhiteSpace(Model.Provisioning.BasicUsername)) + { +
+ + +
+ } +
+ +
+ + +
+
+ + @localizer["CredentialSaved"] + +
+
+
+ +@section Scripts +{ + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Details.cshtml new file mode 100644 index 000000000..4d952324c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Details.cshtml @@ -0,0 +1,232 @@ +@model Resgrid.Web.Areas.User.Models.UnitTracking.UnitTrackingDetailsView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["ViewTrackingStatus"]; +} + +
+
+

@localizer["ViewTrackingStatus"]

+ +
+ @if (Model.CanManage) + { + + } +
+ +
+ @if (TempData["UnitTrackingSuccess"] != null) + { +
@TempData["UnitTrackingSuccess"]
+ } + @if (TempData["UnitTrackingError"] != null) + { +
@TempData["UnitTrackingError"]
+ } + @if (TempData["UnitTrackingPreview"] != null) + { +
@TempData["UnitTrackingPreview"]
+ } + +
+
+
+
@localizer["TrackingStatus"]
+
+
+
@commonLocalizer["Status"]
@Model.Status
+
@localizer["TrackingProfile"]
@Model.Device.ModelKey
+
@localizer["CertificationStatus"]
@(Model.Profile?.CertificationStatus.ToString() ?? "—")
+
@localizer["DeviceIdentifier"]
@(Model.DisplayIdentifier ?? "—")
+
@localizer["FirmwareVersion"]
@(Model.Device.FirmwareVersion ?? "—")
+
@localizer["Protocol"]
@(Model.Device.ProtocolKey ?? "—")
+
@localizer["Transport"]
@((Resgrid.Model.UnitTrackingTransportType)Model.Device.TransportType)
+
@localizer["LastSeen"]
@(Model.Device.LastSeenOn?.ToString("g") ?? localizer["Never"])
+
@localizer["LastValidFix"]
@(Model.Device.LastPositionOn?.ToString("g") ?? localizer["Never"])
+
@localizer["LastReceived"]
@(Model.Device.LastReceivedOn?.ToString("g") ?? localizer["Never"])
+
@localizer["LastErrorCode"]
@(Model.Device.LastErrorCode ?? "—")
+
+
+
+
+
+
+
@localizer["Credentials"]
+
+ @if (!Model.Credentials.Any()) + { +
@localizer["NoCredentials"]
+ } + else + { +
+ + + + + + + + @if (Model.CanManage) + { + + } + + + + @foreach (var credential in Model.Credentials) + { + + + + + + @if (Model.CanManage) + { + + } + + } + +
@localizer["AuthenticationMode"]@localizer["CredentialPrefix"]@localizer["ExpiresOn"]@localizer["LastUsed"]@commonLocalizer["Actions"]
@((Resgrid.Model.UnitTrackingAuthMode)credential.AuthMode)@credential.KeyPrefix…@(credential.ExpiresOn?.ToString("g") ?? "—")@(credential.LastUsedOn?.ToString("g") ?? localizer["Never"]) + @if (!credential.RevokedOn.HasValue) + { +
+ @Html.AntiForgeryToken() + + + + +
+
+ @Html.AntiForgeryToken() + + + +
+ } + else + { + @localizer["Revoked"] + } +
+
+ } + + @if (Model.CanManage && Model.Device.IsEnabled && Model.SupportedAuthModes.Any()) + { +
+

@localizer["CreateCredential"]

+
+ @Html.AntiForgeryToken() + +
+ +
+ +
+
+ + +
+
+ +
+
+
+ } +
+
+
+
+ + @if (Model.CanManage) + { + @if (Model.CanPreviewJson && string.Equals(Model.Device.PayloadAdapterKey, "resgrid-json-v1", StringComparison.OrdinalIgnoreCase)) + { +
+
@localizer["SendTestJson"]
+
+

@localizer["SendTestJsonDescription"]

+
+ @Html.AntiForgeryToken() + +
+ + +
+ +
+
+
+ } + +
+
@localizer["TrackingBindingActions"]
+
+ @if (Model.Device.IsEnabled) + { +
+ @Html.AntiForgeryToken() + + +
+ } +
+ @Html.AntiForgeryToken() + + +
+
+
+ } +
+ +@section Scripts +{ + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Edit.cshtml new file mode 100644 index 000000000..9027a5e6c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Edit.cshtml @@ -0,0 +1,24 @@ +@model Resgrid.Web.Areas.User.Models.UnitTracking.UnitTrackingEditorView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["EditTrackingBinding"]; +} + +
+
+

@localizer["EditTrackingBinding"]

+ +
+
+
+
+
@Model.Unit.Name
+
+ +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Index.cshtml new file mode 100644 index 000000000..ee827349e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Index.cshtml @@ -0,0 +1,88 @@ +@model Resgrid.Web.Areas.User.Models.UnitTracking.UnitTrackingIndexView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["HardwareTracking"]; +} + +
+
+

@localizer["HardwareTracking"]

+ +
+ @if (Model.CanManage) + { + + } +
+ +
+ @if (TempData["UnitTrackingSuccess"] != null) + { +
@TempData["UnitTrackingSuccess"]
+ } +
+
+
@Model.Unit.Name — @localizer["HardwareTracking"]
+
+
+

@localizer["HardwareTrackingDescription"]

+ @if (!Model.Devices.Any()) + { +
@localizer["NoTrackingBindings"]
+ } + else + { +
+ + + + + + + + + + + + + + @foreach (var item in Model.Devices) + { + + + + + + + + + + } + +
@commonLocalizer["Name"]@localizer["TrackingProfile"]@localizer["DeviceIdentifier"]@commonLocalizer["Status"]@localizer["LastSeen"]@localizer["LastValidFix"]@commonLocalizer["Actions"]
@(item.Device.DisplayName ?? item.Device.UnitTrackingDeviceId)@item.Device.ModelKey@(item.DisplayIdentifier ?? "—")@item.Status@(item.Device.LastSeenOn?.ToString("g") ?? localizer["Never"])@(item.Device.LastPositionOn?.ToString("g") ?? localizer["Never"]) + + @localizer["ViewTrackingStatus"] + + @if (Model.CanManage) + { + + @commonLocalizer["Edit"] + + } +
+
+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/New.cshtml new file mode 100644 index 000000000..f97001050 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/New.cshtml @@ -0,0 +1,24 @@ +@model Resgrid.Web.Areas.User.Models.UnitTracking.UnitTrackingEditorView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["AddTrackingBinding"]; +} + +
+
+

@localizer["AddTrackingBinding"]

+ +
+
+
+
+
@Model.Unit.Name
+
+ +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/_Editor.cshtml b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/_Editor.cshtml new file mode 100644 index 000000000..a6683ffbd --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/_Editor.cshtml @@ -0,0 +1,92 @@ +@model Resgrid.Web.Areas.User.Models.UnitTracking.UnitTrackingEditorView +@inject IStringLocalizer localizer + +
+ @Html.AntiForgeryToken() +
+ +
+ +
+ + +

+
+
+ +
+ +
+
+
+ +
+ +

@localizer["DeviceIdentifierHelp"]

+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+ +

@localizer["AllowedSourceCidrsHelp"]

+
+
+ @if (Model.IsEdit) + { +
+ +
+
+ } + +
+
+ @commonLocalizer["Cancel"] + +
+
+
+ + diff --git a/Web/Resgrid.Web/Areas/User/Views/Units/EditUnit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Units/EditUnit.cshtml index 647f8e060..0e8188574 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Units/EditUnit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Units/EditUnit.cshtml @@ -129,6 +129,13 @@ diff --git a/Workers/Resgrid.Workers.Framework/Logic/UnitLocationQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/UnitLocationQueueLogic.cs index e1378e3e5..6f9e43013 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/UnitLocationQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/UnitLocationQueueLogic.cs @@ -2,7 +2,6 @@ using System.Threading; using System.Threading.Tasks; using Autofac; -using Resgrid.Framework; using Resgrid.Model.Services; using Resgrid.Model.Events; using Resgrid.Model; @@ -13,41 +12,59 @@ public class UnitLocationQueueLogic { public static async Task ProcessUnitLocationQueueItem(UnitLocationEvent unitLocationEvent, CancellationToken cancellationToken = default(CancellationToken)) { - bool success = true; + if (unitLocationEvent == null) + throw new ArgumentNullException(nameof(unitLocationEvent)); - if (unitLocationEvent != null) + if (unitLocationEvent.UnitId <= 0) + throw new InvalidOperationException("A Unit location queue event must identify a Unit."); + + if (unitLocationEvent.DepartmentId <= 0) + throw new InvalidOperationException("A Unit location queue event must identify a Department."); + + if (unitLocationEvent.IsValidFix == false) + return true; + + if (!unitLocationEvent.Latitude.HasValue || !unitLocationEvent.Longitude.HasValue) + throw new InvalidOperationException("A valid Unit location queue event must contain latitude and longitude."); + + var unitService = Bootstrapper.GetKernel().Resolve(); + var timestamp = unitLocationEvent.Timestamp == default + ? unitLocationEvent.ReceivedOn ?? DateTime.UtcNow + : unitLocationEvent.Timestamp; + var unitLocation = new UnitsLocation { - try - { - var unitService = Bootstrapper.GetKernel().Resolve(); - - if (unitLocationEvent.Latitude.HasValue && unitLocationEvent.Longitude.HasValue) - { - var unitLocation = new UnitsLocation(); - unitLocation.DepartmentId = unitLocationEvent.DepartmentId; - unitLocation.UnitId = unitLocationEvent.UnitId; - unitLocation.Timestamp = unitLocationEvent.Timestamp; - unitLocation.Latitude = unitLocationEvent.Latitude.Value; - unitLocation.Longitude = unitLocationEvent.Longitude.Value; - unitLocation.Accuracy = unitLocationEvent.Accuracy; - unitLocation.Altitude = unitLocationEvent.Altitude; - unitLocation.AltitudeAccuracy = unitLocationEvent.AltitudeAccuracy; - unitLocation.Speed = unitLocationEvent.Speed; - unitLocation.Heading = unitLocationEvent.Heading; - - if (unitLocation.UnitId > 0) - { - await unitService.AddUnitLocationAsync(unitLocation, unitLocationEvent.DepartmentId, cancellationToken); - } - } - } - catch (Exception ex) - { - Logging.LogException(ex); - } - } - - return success; + EventId = unitLocationEvent.EventId, + DepartmentId = unitLocationEvent.DepartmentId, + UnitId = unitLocationEvent.UnitId, + Timestamp = timestamp, + ReceivedOn = unitLocationEvent.ReceivedOn ?? timestamp, + SourceType = unitLocationEvent.SourceType, + SourceId = unitLocationEvent.SourceId, + SourcePriority = unitLocationEvent.SourcePriority, + TransportType = unitLocationEvent.TransportType, + ProtocolKey = unitLocationEvent.ProtocolKey, + IsValidFix = unitLocationEvent.IsValidFix ?? true, + Latitude = unitLocationEvent.Latitude.Value, + Longitude = unitLocationEvent.Longitude.Value, + Accuracy = unitLocationEvent.Accuracy, + Altitude = unitLocationEvent.Altitude, + AltitudeAccuracy = unitLocationEvent.AltitudeAccuracy, + Speed = unitLocationEvent.Speed, + Heading = unitLocationEvent.Heading, + Satellites = unitLocationEvent.Satellites, + Hdop = unitLocationEvent.Hdop, + BatteryPercent = unitLocationEvent.BatteryPercent, + ExternalPowerVolts = unitLocationEvent.ExternalPowerVolts, + SignalPercent = unitLocationEvent.SignalPercent, + Ignition = unitLocationEvent.Ignition, + IsMoving = unitLocationEvent.IsMoving, + AlarmCode = unitLocationEvent.AlarmCode, + TimestampSource = unitLocationEvent.TimestampSource + }; + + await unitService.AddUnitLocationAsync(unitLocation, unitLocationEvent.DepartmentId, cancellationToken); + + return true; } } } From 1af8c78411ea7021f1fe49880f2131b3da4a869f Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 08:58:53 -0700 Subject: [PATCH 2/8] RG-T127 PR#438 fixes and security fixes --- Core/Resgrid.Config/SecurityConfig.cs | 7 + Core/Resgrid.Config/ServiceBusConfig.cs | 9 +- .../SentryTransactionFilter.cs | 2 + .../Providers/IEventAggregator.cs | 2 +- .../IUnitTrackingCredentialsRepository.cs | 2 + .../Repositories/Queries/IUnitOfWork.cs | 3 + Core/Resgrid.Model/UnitLocationWriteResult.cs | 17 +- .../UnitTrackingAuthenticationService.cs | 17 +- .../UnitTrackingCatalogService.cs | 7 +- .../UnitTrackingIngressService.cs | 47 +- Core/Resgrid.Services/UnitTrackingService.cs | 106 +- .../RabbitConnection.cs | 126 +- .../RabbitOutboundQueueProvider.cs | 8 +- .../Resgrid.Providers.Bus/EventAggregator.cs | 17 +- .../OutboundEventProvider.cs | 2 +- .../Migrations/M0101_AddUnitTracking.cs | 18 +- .../Migrations/M0101_AddUnitTrackingPg.cs | 20 +- .../Resgrid.Providers.Tracking/NOTICE.md | 16 + .../Resgrid.Providers.Tracking/PROVENANCE.md | 45 + ...okeUnitTrackingCredentialsByDeviceQuery.cs | 33 + .../Transactions/UnitOfWork.cs | 23 + .../UnitTrackingCredentialsRepository.cs | 19 + .../UnitTrackingDevicesRepository.cs | 6 +- .../MapLayersDocRepository.cs | 19 +- .../UnitLocationsMongoRepository.cs | 21 +- .../Fixtures/traccar/v6.14.5/README.md | 17 + .../v6.14.5/sinotrack-st901-h02-position.json | 44 + .../Framework/SentryTransactionFilterTests.cs | 53 + Tests/Resgrid.Tests/Mocks/MockUnitOfWork.cs | 3 + Tests/Resgrid.Tests/Resgrid.Tests.csproj | 3 + .../UnitTrackingAuthenticationServiceTests.cs | 47 +- .../UnitTrackingCatalogServiceTests.cs | 4 + .../Services/UnitTrackingServiceTests.cs | 153 +++ .../UnitTrackingIngressControllerTests.cs | 35 + .../UnitTrackingJsonPayloadParserTests.cs | 121 ++ .../Web/User/UnitTrackingControllerTests.cs | 33 + .../UnitTracking/TraccarJsonPayloadAdapter.cs | 298 +++++ .../UnitTrackingHttpAuthenticationService.cs | 28 +- .../UnitTrackingJsonPayloadParser.cs | 34 +- .../Controllers/v4/AvatarsController.cs | 35 +- .../Controllers/v4/CallsController.cs | 4 +- .../v4/PersonnelLocationController.cs | 4 + .../v4/PersonnelStaffingController.cs | 21 +- .../v4/PersonnelStatusesController.cs | 8 +- .../Controllers/v4/UnitStatusController.cs | 12 +- .../v4/UnitTrackingIngressController.cs | 39 +- .../AllowedSourceCidrsAttribute.cs | 46 + .../UnitTracking/UnitTrackingAdminModels.cs | 2 + .../Resgrid.Web.Services.xml | 1123 +++++++++-------- .../Areas/User/Apps/package-lock.json | 12 +- .../User/Controllers/CalendarController.cs | 6 + .../User/Controllers/DepartmentController.cs | 3 + .../User/Controllers/DispatchController.cs | 40 +- .../Areas/User/Controllers/HomeController.cs | 28 + .../User/Controllers/InventoryController.cs | 3 + .../User/Controllers/MappingController.cs | 8 +- .../User/Controllers/MessagesController.cs | 3 + .../Controllers/NotificationsController.cs | 16 + .../User/Controllers/OrdersController.cs | 26 + .../User/Controllers/PersonnelController.cs | 6 +- .../User/Controllers/ProfileController.cs | 6 + .../User/Controllers/ProtocolsController.cs | 3 + .../User/Controllers/ReportsController.cs | 10 +- .../User/Controllers/TemplatesController.cs | 9 + .../User/Controllers/TrainingsController.cs | 23 +- .../Controllers/UnitTrackingController.cs | 6 +- .../User/Controllers/WorkshiftsController.cs | 7 +- .../User/Views/Dispatch/CallExportEx.cshtml | 2 +- .../User/Views/UnitTracking/Details.cshtml | 6 +- .../User/Views/UnitTracking/_Editor.cshtml | 2 +- .../wwwroot/js/ng/react-elements.css | 810 ++++++------ .../wwwroot/js/ng/react-elements.js | 2 +- .../Logic/ReportDeliveryLogic.cs | 7 +- .../Logic/UnitLocationQueueLogic.cs | 104 +- 74 files changed, 2686 insertions(+), 1221 deletions(-) create mode 100644 Providers/Resgrid.Providers.Tracking/NOTICE.md create mode 100644 Providers/Resgrid.Providers.Tracking/PROVENANCE.md create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/RevokeUnitTrackingCredentialsByDeviceQuery.cs create mode 100644 Tests/Resgrid.Tests/Data/UnitTracking/Fixtures/traccar/v6.14.5/README.md create mode 100644 Tests/Resgrid.Tests/Data/UnitTracking/Fixtures/traccar/v6.14.5/sinotrack-st901-h02-position.json create mode 100644 Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/TraccarJsonPayloadAdapter.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/UnitTracking/AllowedSourceCidrsAttribute.cs diff --git a/Core/Resgrid.Config/SecurityConfig.cs b/Core/Resgrid.Config/SecurityConfig.cs index 177c0f41a..eee8f771d 100644 --- a/Core/Resgrid.Config/SecurityConfig.cs +++ b/Core/Resgrid.Config/SecurityConfig.cs @@ -25,6 +25,13 @@ public static class SecurityConfig ///
public static string SystemApiKey = ""; + /// + /// Shared secret required on the internal scheduled-report endpoint (User/Reports/InternalRunReport). + /// The report-delivery worker supplies this value as the "key" query parameter; requests without a + /// matching key are rejected. Must be set (non-empty) for scheduled report delivery to function. + /// + public static string InternalReportsToken = ""; + // ── Encryption ─────────────────────────────────────────────────────────────── /// AES-256 master key used by IEncryptionService for system-wide encryption. diff --git a/Core/Resgrid.Config/ServiceBusConfig.cs b/Core/Resgrid.Config/ServiceBusConfig.cs index 9e164fb85..75e40d62c 100644 --- a/Core/Resgrid.Config/ServiceBusConfig.cs +++ b/Core/Resgrid.Config/ServiceBusConfig.cs @@ -15,6 +15,9 @@ public static class ServiceBusConfig public static string PaymentQueueName = "paymenttest"; public static string AuditQueueName = "audittest"; public static string UnitLoactionQueueName = "unitlocationtest"; + public static string UnitLocationQueueV2Name = "unitlocation-v2test"; + public static string UnitLocationRetryQueueV2Name = "unitlocation-v2test.retry"; + public static string UnitLocationDeadQueueV2Name = "unitlocation-v2test.dead"; public static string PersonnelLoactionQueueName = "personnellocationtest"; public static string SecurityRefreshQueueName = "securityrefreshtest"; public static string WorkflowQueueName = "workflowqueuetest"; @@ -29,6 +32,9 @@ public static class ServiceBusConfig public static string PaymentQueueName = "payment"; public static string AuditQueueName = "audit"; public static string UnitLoactionQueueName = "unitlocation"; + public static string UnitLocationQueueV2Name = "unitlocation-v2"; + public static string UnitLocationRetryQueueV2Name = "unitlocation-v2.retry"; + public static string UnitLocationDeadQueueV2Name = "unitlocation-v2.dead"; public static string PersonnelLoactionQueueName = "personnellocation"; public static string SecurityRefreshQueueName = "securityrefresh"; public static string WorkflowQueueName = "workflowqueue"; @@ -50,9 +56,6 @@ public static class ServiceBusConfig public static string RabbitUsername = ""; public static string RabbbitPassword = ""; public static string RabbbitExchange = ""; - public static string UnitLocationQueueV2Name = "unitlocation-v2"; - public static string UnitLocationRetryQueueV2Name = "unitlocation-v2.retry"; - public static string UnitLocationDeadQueueV2Name = "unitlocation-v2.dead"; #endregion RabbitMQ Bus Values } diff --git a/Core/Resgrid.Framework/SentryTransactionFilter.cs b/Core/Resgrid.Framework/SentryTransactionFilter.cs index 833fbc4ac..08c7d2e05 100644 --- a/Core/Resgrid.Framework/SentryTransactionFilter.cs +++ b/Core/Resgrid.Framework/SentryTransactionFilter.cs @@ -67,6 +67,8 @@ public static SentryTransaction Filter(SentryTransaction transaction) if (transaction.Request != null) transaction.Request.Url = RedactCapabilityPath(transaction.Request.Url); + ((IEventLike)transaction).TransactionName = RedactCapabilityPath(transaction.Name); + if (transaction.Status != SpanStatus.NotFound) return transaction; diff --git a/Core/Resgrid.Model/Providers/IEventAggregator.cs b/Core/Resgrid.Model/Providers/IEventAggregator.cs index bbaebc8e1..bcbd30158 100644 --- a/Core/Resgrid.Model/Providers/IEventAggregator.cs +++ b/Core/Resgrid.Model/Providers/IEventAggregator.cs @@ -26,7 +26,7 @@ public interface IEventSubscriptionManager /// Returns the current IEventSubscriptionManager to allow for easy fluent additions. Guid AddListener(Action listener); - Guid AddAsyncListener(Func listener); + Guid AddAsyncListener(Func listener, Action onError = null); /// /// Removes the listener object from the EventAggregator diff --git a/Core/Resgrid.Model/Repositories/IUnitTrackingCredentialsRepository.cs b/Core/Resgrid.Model/Repositories/IUnitTrackingCredentialsRepository.cs index fa8df6f09..5e28fe8c8 100644 --- a/Core/Resgrid.Model/Repositories/IUnitTrackingCredentialsRepository.cs +++ b/Core/Resgrid.Model/Repositories/IUnitTrackingCredentialsRepository.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -7,5 +8,6 @@ public interface IUnitTrackingCredentialsRepository : IRepository> GetAllByDeviceIdAsync(string unitTrackingDeviceId); Task GetBySecretHashAsync(string secretHash); + Task RevokeAllByDeviceIdAsync(string unitTrackingDeviceId, DateTime revokedOn); } } diff --git a/Core/Resgrid.Model/Repositories/Queries/IUnitOfWork.cs b/Core/Resgrid.Model/Repositories/Queries/IUnitOfWork.cs index 859e0623a..bb995a068 100644 --- a/Core/Resgrid.Model/Repositories/Queries/IUnitOfWork.cs +++ b/Core/Resgrid.Model/Repositories/Queries/IUnitOfWork.cs @@ -1,5 +1,7 @@ using System; using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; namespace Resgrid.Model.Repositories.Queries { @@ -8,6 +10,7 @@ public interface IUnitOfWork : IDisposable DbTransaction Transaction { get; } DbConnection Connection { get; } DbConnection CreateOrGetConnection(); + Task CreateOrGetConnectionAsync(CancellationToken cancellationToken = default(CancellationToken)); void DiscardChanges(); void CommitChanges(); } diff --git a/Core/Resgrid.Model/UnitLocationWriteResult.cs b/Core/Resgrid.Model/UnitLocationWriteResult.cs index 21ad4b1ea..21c57025a 100644 --- a/Core/Resgrid.Model/UnitLocationWriteResult.cs +++ b/Core/Resgrid.Model/UnitLocationWriteResult.cs @@ -11,20 +11,17 @@ public class UnitLocationWriteResult public UnitLocationWriteStatus Status { get; set; } public UnitsLocation Location { get; set; } - public static UnitLocationWriteResult Inserted(UnitsLocation location) - { - return new UnitLocationWriteResult - { - Status = UnitLocationWriteStatus.Inserted, - Location = location - }; - } + public static UnitLocationWriteResult Inserted(UnitsLocation location) => + Create(UnitLocationWriteStatus.Inserted, location); + + public static UnitLocationWriteResult Duplicate(UnitsLocation location) => + Create(UnitLocationWriteStatus.Duplicate, location); - public static UnitLocationWriteResult Duplicate(UnitsLocation location) + private static UnitLocationWriteResult Create(UnitLocationWriteStatus status, UnitsLocation location) { return new UnitLocationWriteResult { - Status = UnitLocationWriteStatus.Duplicate, + Status = status, Location = location }; } diff --git a/Core/Resgrid.Services/UnitTrackingAuthenticationService.cs b/Core/Resgrid.Services/UnitTrackingAuthenticationService.cs index 38a2d7f60..6c5654a63 100644 --- a/Core/Resgrid.Services/UnitTrackingAuthenticationService.cs +++ b/Core/Resgrid.Services/UnitTrackingAuthenticationService.cs @@ -36,12 +36,10 @@ public UnitTrackingGeneratedCredential GenerateCredential() { EnsurePepperConfigured(); - var randomBytes = RandomNumberGenerator.GetBytes(32); - var encodedSecret = Convert.ToBase64String(randomBytes) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - var keyPrefix = encodedSecret.Substring(0, 8); + var secretBytes = RandomNumberGenerator.GetBytes(32); + var prefixBytes = RandomNumberGenerator.GetBytes(6); + var encodedSecret = EncodeBase64Url(secretBytes); + var keyPrefix = EncodeBase64Url(prefixBytes); var token = $"rgtrk_{keyPrefix}_{encodedSecret}"; return new UnitTrackingGeneratedCredential @@ -198,6 +196,7 @@ async Task> load() load, TimeSpan.FromSeconds(Math.Max(1, Math.Min(60, UnitTrackingConfig.CredentialCacheSeconds)))) : await load(); + cached ??= new List(); var now = utcNow ?? DateTime.UtcNow; return cached @@ -252,6 +251,12 @@ private static bool IsEnabled(UnitTrackingDevice device) => private static string NormalizeKey(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + private static string EncodeBase64Url(byte[] value) => + Convert.ToBase64String(value) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + private static UnitTrackingCredential SanitizeCredential(UnitTrackingCredential credential) { return new UnitTrackingCredential diff --git a/Core/Resgrid.Services/UnitTrackingCatalogService.cs b/Core/Resgrid.Services/UnitTrackingCatalogService.cs index 09e7ebbc3..4e3441948 100644 --- a/Core/Resgrid.Services/UnitTrackingCatalogService.cs +++ b/Core/Resgrid.Services/UnitTrackingCatalogService.cs @@ -54,12 +54,13 @@ public class UnitTrackingCatalogService : IUnitTrackingCatalogService { UnitTrackingAuthMode.Bearer, UnitTrackingAuthMode.Basic, - UnitTrackingAuthMode.CustomHeader + UnitTrackingAuthMode.CustomHeader, + UnitTrackingAuthMode.CapabilityPath }, SetupSummary = - "The pinned Traccar forwarding adapter is delivered in WP5. Candidate profiles cannot be selected in production.", + "Configure Traccar v6.14.5 JSON position forwarding. Use a per-device capability URL when one Traccar server forwards multiple devices.", RetryExpectation = - "Configure position-forwarding retries for 429 and 503 responses." + "Enable Traccar position-forwarding retries. Resgrid returns 202 after durable queue acceptance and 429 or 503 for retryable failures." } }; diff --git a/Core/Resgrid.Services/UnitTrackingIngressService.cs b/Core/Resgrid.Services/UnitTrackingIngressService.cs index 17782c078..01d7e8b5f 100644 --- a/Core/Resgrid.Services/UnitTrackingIngressService.cs +++ b/Core/Resgrid.Services/UnitTrackingIngressService.cs @@ -53,33 +53,34 @@ public async Task AcceptAsync( if (!IsEnabled(source?.Device)) return Invalid(receivedOn, "The tracking binding is not enabled."); - var device = source.Device; - var unit = await _unitsService.GetUnitByIdAsync(device.UnitId); - if (unit == null || unit.DepartmentId != device.DepartmentId) - { - await TryUpdateDeviceStatusAsync(device, receivedOn, null, "tenant-binding-invalid", cancellationToken); - return Invalid(receivedOn, "The tracking binding is invalid."); - } + var device = source.Device; - if (!IdentifierMatches(device.DeviceIdentifier, source.ReportedDeviceIdentifier)) - { - await TryUpdateDeviceStatusAsync(device, receivedOn, null, "identifier-mismatch", cancellationToken); - return Invalid(receivedOn, "The reported device identifier does not match the binding."); - } + if (positions == null || positions.Count == 0) + { + await TryUpdateDeviceStatusAsync(device, receivedOn, null, "empty-payload", cancellationToken); + return Invalid(receivedOn, "At least one position is required."); + } - if (positions == null || positions.Count == 0) - { - await TryUpdateDeviceStatusAsync(device, receivedOn, null, "empty-payload", cancellationToken); - return Invalid(receivedOn, "At least one position is required."); - } + if (positions.Count > Math.Max(1, UnitTrackingConfig.MaxBatchPositions)) + { + await TryUpdateDeviceStatusAsync(device, receivedOn, null, "batch-limit", cancellationToken); + return Invalid(receivedOn, "The position batch exceeds the configured limit."); + } - if (positions.Count > Math.Max(1, UnitTrackingConfig.MaxBatchPositions)) - { - await TryUpdateDeviceStatusAsync(device, receivedOn, null, "batch-limit", cancellationToken); - return Invalid(receivedOn, "The position batch exceeds the configured limit."); - } + var unit = await _unitsService.GetUnitByIdAsync(device.UnitId); + if (unit == null || unit.DepartmentId != device.DepartmentId) + { + await TryUpdateDeviceStatusAsync(device, receivedOn, null, "tenant-binding-invalid", cancellationToken); + return Invalid(receivedOn, "The tracking binding is invalid."); + } + + if (!IdentifierMatches(device.DeviceIdentifier, source.ReportedDeviceIdentifier)) + { + await TryUpdateDeviceStatusAsync(device, receivedOn, null, "identifier-mismatch", cancellationToken); + return Invalid(receivedOn, "The reported device identifier does not match the binding."); + } - var retentionDays = + var retentionDays = await _departmentSettingsService.GetHardwareTrackingLocationRetentionDaysAsync(device.DepartmentId); var normalization = NormalizeAll(positions, retentionDays); if (normalization.Errors.Count > 0) diff --git a/Core/Resgrid.Services/UnitTrackingService.cs b/Core/Resgrid.Services/UnitTrackingService.cs index 79d99fa6e..e7705bddd 100644 --- a/Core/Resgrid.Services/UnitTrackingService.cs +++ b/Core/Resgrid.Services/UnitTrackingService.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Text; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Resgrid.Config; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; using Resgrid.Model.Providers; @@ -99,7 +101,7 @@ public async Task CreateDeviceAsync( IsEnabled = device.IsEnabled, IsDeleted = false, SourcePriority = device.SourcePriority, - AllowedSourceCidrs = TrimToNull(device.AllowedSourceCidrs), + AllowedSourceCidrs = ValidateAllowedSourceCidrs(device.AllowedSourceCidrs), LastStatus = device.IsEnabled ? (int)UnitTrackingDeviceStatus.NeverSeen : (int)UnitTrackingDeviceStatus.Disabled, @@ -132,9 +134,7 @@ public async Task UpdateDeviceAsync( "A physical tracker must be rebound by creating a new binding; UnitId cannot be edited in place."); } - if (existing.IsEnabled && !device.IsEnabled) - return await DisableDeviceAsync(existing.UnitTrackingDeviceId, departmentId, userId, cancellationToken); - + var disableRequested = existing.IsEnabled && !device.IsEnabled; await ValidateUnitOwnershipAsync(existing.UnitId, departmentId); var credentials = await GetCredentialEntitiesAsync(existing.UnitTrackingDeviceId); var before = DeviceAuditSnapshot(existing); @@ -150,10 +150,23 @@ public async Task UpdateDeviceAsync( existing.SecondaryIdentifier = _identifierService.Normalize(device.SecondaryIdentifier); existing.IsEnabled = device.IsEnabled; existing.SourcePriority = device.SourcePriority; - existing.AllowedSourceCidrs = TrimToNull(device.AllowedSourceCidrs); + existing.AllowedSourceCidrs = ValidateAllowedSourceCidrs(device.AllowedSourceCidrs); existing.FirmwareVersion = TrimToNull(device.FirmwareVersion); existing.UpdatedByUserId = userId; existing.UpdatedOn = DateTime.UtcNow; + if (disableRequested) + { + return await PersistDisabledOrDeletedDeviceAsync( + existing, + oldCacheIdentity, + credentials, + before, + departmentId, + userId, + false, + cancellationToken); + } + if (device.IsEnabled && existing.LastStatus == (int)UnitTrackingDeviceStatus.Disabled) existing.LastStatus = (int)UnitTrackingDeviceStatus.NeverSeen; @@ -199,10 +212,11 @@ public async Task RebindDeviceAsync( var oldCacheIdentity = CopyCacheIdentity(existing); var now = DateTime.UtcNow; - _unitOfWork.CreateOrGetConnection(); + await _unitOfWork.CreateOrGetConnectionAsync(cancellationToken); UnitTrackingDevice replacement; try { + replacement = CopyForRebind(existing, newUnitId, userId, now); existing.IsEnabled = false; existing.IsDeleted = true; existing.LastStatus = (int)UnitTrackingDeviceStatus.Disabled; @@ -211,13 +225,13 @@ public async Task RebindDeviceAsync( await _devicesRepository.UpdateAsync(existing, cancellationToken); await RevokeCredentialsAsync(credentials, now, cancellationToken); - replacement = CopyForRebind(existing, newUnitId, userId, now); await _devicesRepository.InsertAsync(replacement, cancellationToken); _unitOfWork.CommitChanges(); } - catch + catch (Exception ex) { _unitOfWork.DiscardChanges(); + Logging.LogException(ex, $"Unit tracking device rebind failed for device {deviceId} in department {departmentId}."); throw; } @@ -383,6 +397,27 @@ private async Task DisableOrDeleteDeviceAsync( var before = DeviceAuditSnapshot(device); var oldCacheIdentity = CopyCacheIdentity(device); var credentials = await GetCredentialEntitiesAsync(deviceId); + return await PersistDisabledOrDeletedDeviceAsync( + device, + oldCacheIdentity, + credentials, + before, + departmentId, + userId, + delete, + cancellationToken); + } + + private async Task PersistDisabledOrDeletedDeviceAsync( + UnitTrackingDevice device, + UnitTrackingDevice oldCacheIdentity, + IEnumerable credentials, + object before, + int departmentId, + string userId, + bool delete, + CancellationToken cancellationToken) + { var now = DateTime.UtcNow; _unitOfWork.CreateOrGetConnection(); @@ -466,11 +501,13 @@ private async Task RevokeCredentialsAsync( DateTime revokedOn, CancellationToken cancellationToken) { - foreach (var credential in credentials.Where(item => !item.RevokedOn.HasValue)) - { + var pending = credentials.Where(item => !item.RevokedOn.HasValue).ToList(); + if (pending.Count == 0) + return; + + await _credentialsRepository.RevokeAllByDeviceIdAsync(pending[0].UnitTrackingDeviceId, revokedOn); + foreach (var credential in pending) credential.RevokedOn = revokedOn; - await _credentialsRepository.UpdateAsync(credential, cancellationToken); - } } private async Task InvalidateDeviceAndCredentialsAsync( @@ -649,11 +686,13 @@ private static UnitTrackingDevice CopyForRebind( PayloadAdapterKey = device.PayloadAdapterKey, DeviceIdentifier = device.DeviceIdentifier, SecondaryIdentifier = device.SecondaryIdentifier, - IsEnabled = true, + IsEnabled = device.IsEnabled, IsDeleted = false, SourcePriority = device.SourcePriority, AllowedSourceCidrs = device.AllowedSourceCidrs, - LastStatus = (int)UnitTrackingDeviceStatus.NeverSeen, + LastStatus = device.IsEnabled + ? (int)UnitTrackingDeviceStatus.NeverSeen + : (int)UnitTrackingDeviceStatus.Disabled, FirmwareVersion = device.FirmwareVersion, CreatedByUserId = userId, CreatedOn = now @@ -693,7 +732,9 @@ private static void ValidateAuthMode( } private static bool IsHttpTokenCharacter(char character) => - char.IsLetterOrDigit(character) || + (character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || "!#$%&'*+-.^_`|~".Contains(character); private static void ValidateUserAndDepartment(string userId, int departmentId) @@ -707,6 +748,41 @@ private static void ValidateUserAndDepartment(string userId, int departmentId) private static string NormalizeKey(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + private static string ValidateAllowedSourceCidrs(string allowedSourceCidrs) + { + if (string.IsNullOrWhiteSpace(allowedSourceCidrs)) + return null; + + var entries = allowedSourceCidrs.Split( + ',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (entries.Length == 0) + { + throw new ArgumentException( + "Allowed source CIDRs must contain at least one canonical CIDR entry.", + nameof(allowedSourceCidrs)); + } + + foreach (var entry in entries) + { + var parts = entry.Split('/', 2, StringSplitOptions.None); + if (parts.Length != 2 || + !IPAddress.TryParse(parts[0], out var address) || + !int.TryParse(parts[1], out var prefixLength) || + prefixLength < 0 || + prefixLength > address.GetAddressBytes().Length * 8 || + !IPNetwork.TryParse(entry, out var network) || + !string.Equals(entry, network.ToString(), StringComparison.Ordinal)) + { + throw new ArgumentException( + "Allowed source CIDRs must contain only canonical IPv4 or IPv6 CIDR entries.", + nameof(allowedSourceCidrs)); + } + } + + return string.Join(",", entries); + } + private static string TrimToNull(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs index 07772dbb1..ef5e17219 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs @@ -133,58 +133,13 @@ await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.Audit autoDelete: false, arguments: null); - await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.UnitLoactionQueueName), - durable: false, - exclusive: false, - autoDelete: false, - arguments: null); - - var unitLocationQueueV2Name = SetQueueNameForEnv(ServiceBusConfig.UnitLocationQueueV2Name); - var unitLocationRetryQueueV2Name = SetQueueNameForEnv(ServiceBusConfig.UnitLocationRetryQueueV2Name); - var unitLocationDeadQueueV2Name = SetQueueNameForEnv(ServiceBusConfig.UnitLocationDeadQueueV2Name); - var queueExchange = ServiceBusConfig.RabbbitExchange ?? string.Empty; - var queueTtlMilliseconds = (long)Math.Max(1, UnitTrackingConfig.QueueMessageTtlSeconds) * 1000L; - var retryTtlMilliseconds = (long)Math.Max(1, UnitTrackingConfig.UnitLocationRetryDelaySeconds) * 1000L; - - await channel.QueueDeclareAsync( - queue: unitLocationDeadQueueV2Name, - durable: true, - exclusive: false, - autoDelete: false, - arguments: null); - - await channel.QueueDeclareAsync( - queue: unitLocationRetryQueueV2Name, - durable: true, - exclusive: false, - autoDelete: false, - arguments: new System.Collections.Generic.Dictionary - { - ["x-message-ttl"] = retryTtlMilliseconds, - ["x-dead-letter-exchange"] = queueExchange, - ["x-dead-letter-routing-key"] = unitLocationQueueV2Name - }); - - await channel.QueueDeclareAsync( - queue: unitLocationQueueV2Name, - durable: true, - exclusive: false, - autoDelete: false, - arguments: new System.Collections.Generic.Dictionary - { - ["x-message-ttl"] = queueTtlMilliseconds, - ["x-dead-letter-exchange"] = queueExchange, - ["x-dead-letter-routing-key"] = unitLocationDeadQueueV2Name - }); + await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.UnitLoactionQueueName), + durable: false, + exclusive: false, + autoDelete: false, + arguments: null); - if (!string.IsNullOrWhiteSpace(queueExchange)) - { - await channel.QueueBindAsync(unitLocationQueueV2Name, queueExchange, unitLocationQueueV2Name); - await channel.QueueBindAsync(unitLocationRetryQueueV2Name, queueExchange, unitLocationRetryQueueV2Name); - await channel.QueueBindAsync(unitLocationDeadQueueV2Name, queueExchange, unitLocationDeadQueueV2Name); - } - - await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.PersonnelLoactionQueueName), + await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.PersonnelLoactionQueueName), durable: false, exclusive: false, autoDelete: false, @@ -208,13 +163,15 @@ await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.Workf autoDelete: false, arguments: null); - await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.ChatbotProcessingQueueName), - durable: true, - exclusive: false, - autoDelete: false, - arguments: null); + await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.ChatbotProcessingQueueName), + durable: true, + exclusive: false, + autoDelete: false, + arguments: null); - return true; + await DeclareUnitLocationV2QueuesAsync(channel); + + return true; } catch (Exception ex) { @@ -227,6 +184,61 @@ await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.Chatb return false; } + private static async Task DeclareUnitLocationV2QueuesAsync(IChannel channel) + { + try + { + var unitLocationQueueV2Name = SetQueueNameForEnv(ServiceBusConfig.UnitLocationQueueV2Name); + var unitLocationRetryQueueV2Name = SetQueueNameForEnv(ServiceBusConfig.UnitLocationRetryQueueV2Name); + var unitLocationDeadQueueV2Name = SetQueueNameForEnv(ServiceBusConfig.UnitLocationDeadQueueV2Name); + var queueExchange = ServiceBusConfig.RabbbitExchange ?? string.Empty; + var queueTtlMilliseconds = (long)Math.Max(1, UnitTrackingConfig.QueueMessageTtlSeconds) * 1000L; + var retryTtlMilliseconds = (long)Math.Max(1, UnitTrackingConfig.UnitLocationRetryDelaySeconds) * 1000L; + + await channel.QueueDeclareAsync( + queue: unitLocationDeadQueueV2Name, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null); + + await channel.QueueDeclareAsync( + queue: unitLocationRetryQueueV2Name, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new System.Collections.Generic.Dictionary + { + ["x-message-ttl"] = retryTtlMilliseconds, + ["x-dead-letter-exchange"] = queueExchange, + ["x-dead-letter-routing-key"] = unitLocationQueueV2Name + }); + + await channel.QueueDeclareAsync( + queue: unitLocationQueueV2Name, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new System.Collections.Generic.Dictionary + { + ["x-message-ttl"] = queueTtlMilliseconds, + ["x-dead-letter-exchange"] = queueExchange, + ["x-dead-letter-routing-key"] = unitLocationDeadQueueV2Name + }); + + if (!string.IsNullOrWhiteSpace(queueExchange)) + { + await channel.QueueBindAsync(unitLocationQueueV2Name, queueExchange, unitLocationQueueV2Name); + await channel.QueueBindAsync(unitLocationRetryQueueV2Name, queueExchange, unitLocationRetryQueueV2Name); + await channel.QueueBindAsync(unitLocationDeadQueueV2Name, queueExchange, unitLocationDeadQueueV2Name); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } + public static async Task CreateConnection(string clientName) { if (_connection == null) diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs index d416c5db3..0e58a54c2 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using Resgrid.Config; using Resgrid.Model.Queue; using Resgrid.Framework; @@ -190,8 +190,8 @@ await channel.BasicPublishAsync( routingKey: RabbitConnection.SetQueueNameForEnv(queueName), mandatory: true, basicProperties: props, - body: Encoding.ASCII.GetBytes(message), - cancellationToken: publishTimeout?.Token ?? default); + body: Encoding.UTF8.GetBytes(message), + cancellationToken: publishTimeout?.Token ?? default); return true; } @@ -261,7 +261,7 @@ await channel.BasicPublishAsync( routingKey: RabbitConnection.SetQueueNameForEnv(queueName), mandatory: true, basicProperties: props, - body: Encoding.ASCII.GetBytes(message), + body: Encoding.UTF8.GetBytes(message), cancellationToken: publishTimeout.Token); } diff --git a/Providers/Resgrid.Providers.Bus/EventAggregator.cs b/Providers/Resgrid.Providers.Bus/EventAggregator.cs index 462b29651..80ef56c9f 100644 --- a/Providers/Resgrid.Providers.Bus/EventAggregator.cs +++ b/Providers/Resgrid.Providers.Bus/EventAggregator.cs @@ -36,7 +36,7 @@ public Guid AddListener(Action listener) return _hub.Subscribe(listener); } - public Guid AddAsyncListener(Func listener) + public Guid AddAsyncListener(Func listener, Action onError = null) { if (listener == null) throw new ArgumentNullException(nameof(listener)); @@ -45,7 +45,20 @@ public Guid AddAsyncListener(Func listener) var listeners = _asyncListeners.GetOrAdd( typeof(T), _ => new ConcurrentDictionary>()); - listeners[token] = message => listener((T)message); + listeners[token] = async message => + { + try + { + await listener((T)message); + } + catch (Exception ex) + { + if (onError == null) + throw; + + onError(ex); + } + }; return token; } diff --git a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs index f4a535e7f..a4fce4184 100644 --- a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs @@ -616,7 +616,7 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro _rabbitTopicProvider = new RabbitTopicProvider(); if (!await _rabbitTopicProvider.UnitLocationUpdatedChanged(message)) - throw new InvalidOperationException("Unable to publish the Unit location realtime update."); + Framework.Logging.LogError($"Unable to publish the Unit location realtime update for department {message.DepartmentId}."); }; #endregion Topic Based Events } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0101_AddUnitTracking.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0101_AddUnitTracking.cs index 2f60dda85..4f2838719 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0101_AddUnitTracking.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0101_AddUnitTracking.cs @@ -39,13 +39,16 @@ public override void Up() .WithColumn("UpdatedByUserId").AsString(128).Nullable() .WithColumn("UpdatedOn").AsDateTime().Nullable(); - Create.ForeignKey("FK_UnitTrackingDevices_Departments") - .FromTable(DevicesTable).ForeignColumn("DepartmentId") - .ToTable("Departments").PrimaryColumn("DepartmentId"); + if (!Schema.Table("Units").Constraint("UQ_Units_DepartmentId_UnitId").Exists()) + { + Create.UniqueConstraint("UQ_Units_DepartmentId_UnitId") + .OnTable("Units") + .Columns("DepartmentId", "UnitId"); + } - Create.ForeignKey("FK_UnitTrackingDevices_Units") - .FromTable(DevicesTable).ForeignColumn("UnitId") - .ToTable("Units").PrimaryColumn("UnitId"); + Create.ForeignKey("FK_UnitTrackingDevices_Units_Department_Unit") + .FromTable(DevicesTable).ForeignColumns("DepartmentId", "UnitId") + .ToTable("Units").PrimaryColumns("DepartmentId", "UnitId"); } if (!Schema.Table(DevicesTable).Index("IX_UnitTrackingDevices_Department_Unit_Deleted").Exists()) @@ -139,6 +142,9 @@ public override void Down() if (Schema.Table(DevicesTable).Exists()) Delete.Table(DevicesTable); + + if (Schema.Table("Units").Constraint("UQ_Units_DepartmentId_UnitId").Exists()) + Delete.UniqueConstraint("UQ_Units_DepartmentId_UnitId").FromTable("Units"); } } } diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs index 39a3deb07..81c3da26c 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs @@ -39,13 +39,16 @@ public override void Up() .WithColumn("updatedbyuserid").AsCustom("citext").Nullable() .WithColumn("updatedon").AsDateTime2().Nullable(); - Create.ForeignKey("fk_unittrackingdevices_departments") - .FromTable(DevicesTable).ForeignColumn("departmentid") - .ToTable("departments").PrimaryColumn("departmentid"); + if (!Schema.Table("units").Constraint("uq_units_departmentid_unitid").Exists()) + { + Create.UniqueConstraint("uq_units_departmentid_unitid") + .OnTable("units") + .Columns("departmentid", "unitid"); + } - Create.ForeignKey("fk_unittrackingdevices_units") - .FromTable(DevicesTable).ForeignColumn("unitid") - .ToTable("units").PrimaryColumn("unitid"); + Create.ForeignKey("fk_unittrackingdevices_units_department_unit") + .FromTable(DevicesTable).ForeignColumns("departmentid", "unitid") + .ToTable("units").PrimaryColumns("departmentid", "unitid"); } if (!Schema.Table(DevicesTable).Index("ix_unittrackingdevices_department_unit_deleted").Exists()) @@ -87,7 +90,7 @@ ON unittrackingdevices (COALESCE(protocolkey, ''), deviceidentifier) .WithColumn("headername").AsCustom("citext").Nullable() .WithColumn("basicusername").AsCustom("citext").Nullable() .WithColumn("keyprefix").AsCustom("citext").NotNullable() - .WithColumn("secrethash").AsCustom("citext").NotNullable() + .WithColumn("secrethash").AsString(64).NotNullable() .WithColumn("validfrom").AsDateTime2().NotNullable() .WithColumn("expireson").AsDateTime2().Nullable() .WithColumn("revokedon").AsDateTime2().Nullable() @@ -132,6 +135,9 @@ public override void Down() if (Schema.Table(DevicesTable).Exists()) Delete.Table(DevicesTable); + + if (Schema.Table("units").Constraint("uq_units_departmentid_unitid").Exists()) + Delete.UniqueConstraint("uq_units_departmentid_unitid").FromTable("units"); } } } diff --git a/Providers/Resgrid.Providers.Tracking/NOTICE.md b/Providers/Resgrid.Providers.Tracking/NOTICE.md new file mode 100644 index 000000000..c494ba6ed --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/NOTICE.md @@ -0,0 +1,16 @@ +# Third-party notices + +## Traccar + +The Resgrid `traccar-json-v1` interoperability adapter was independently implemented +against Traccar's public JSON position-forwarding contract. + +- Project: Traccar GPS Tracking System +- Repository: https://github.com/traccar/traccar +- Pinned version: `v6.14.5` +- Pinned commit: `5c5e710d5e357912f1b30561ed54bfd07a5d42f9` +- License: Apache License 2.0 +- Copyright: Anton Tananaev and Traccar contributors + +No Traccar Java source is compiled into or redistributed with Resgrid. See +`PROVENANCE.md` for the exact files reviewed and the adaptation record. diff --git a/Providers/Resgrid.Providers.Tracking/PROVENANCE.md b/Providers/Resgrid.Providers.Tracking/PROVENANCE.md new file mode 100644 index 000000000..50e382171 --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/PROVENANCE.md @@ -0,0 +1,45 @@ +# Tracking adapter provenance + +## `traccar-json-v1` + +### Pin + +- Upstream repository: https://github.com/traccar/traccar +- Release: `v6.14.5` +- Commit: `5c5e710d5e357912f1b30561ed54bfd07a5d42f9` +- Commit date: 2026-06-18 +- License: Apache License 2.0 + +### Public material reviewed + +- Position-forwarding configuration: https://www.traccar.org/forward/ +- `src/main/java/org/traccar/forward/PositionData.java` +- `src/main/java/org/traccar/forward/PositionForwarderJson.java` +- `src/main/java/org/traccar/model/Position.java` +- `src/main/java/org/traccar/model/Device.java` +- `src/main/java/org/traccar/model/Message.java` +- `src/main/java/org/traccar/model/ExtendedModel.java` +- `src/main/java/org/traccar/model/BaseModel.java` + +All source-file references above are pinned to commit +`5c5e710d5e357912f1b30561ed54bfd07a5d42f9`. + +### Adaptation record + +The Resgrid adapter is an independent C# implementation of the serialized forwarding +contract. No Java implementation code was copied or ported. The mapping intentionally: + +- accepts the `PositionData` envelope's `position` and `device` objects; +- uses `device.uniqueId` only for defense-in-depth binding validation; +- cross-checks Traccar's internal device IDs; +- converts `Position.speed` from knots to meters per second; +- maps only allowlisted health and alarm attributes; +- ignores unrecognized fields; +- derives a deterministic SHA-256 retry fingerprint when `Position.id` is zero. + +### Fixtures + +`Tests/Resgrid.Tests/Data/UnitTracking/Fixtures/traccar/v6.14.5/` contains an +independently generated, sanitized fixture representing a long-tail SinoTrack ST-901 +decoded by Traccar's `h02` protocol. It contains no upstream or customer packet data and +does not constitute physical-hardware certification. diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/RevokeUnitTrackingCredentialsByDeviceQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/RevokeUnitTrackingCredentialsByDeviceQuery.cs new file mode 100644 index 000000000..c9fa2a4a4 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/UnitTracking/RevokeUnitTrackingCredentialsByDeviceQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository.Queries.UnitTracking +{ + public class RevokeUnitTrackingCredentialsByDeviceQuery : IUpdateQuery + { + private readonly SqlConfiguration _sqlConfiguration; + + public RevokeUnitTrackingCredentialsByDeviceQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery(TEntity entity) + { + if (DataConfig.DatabaseType == DatabaseTypes.Postgres) + { + return $@"UPDATE {_sqlConfiguration.SchemaName}.unittrackingcredentials + SET revokedon = {_sqlConfiguration.ParameterNotation}RevokedOn + WHERE unittrackingdeviceid = {_sqlConfiguration.ParameterNotation}UnitTrackingDeviceId + AND revokedon IS NULL"; + } + + return $@"UPDATE {_sqlConfiguration.SchemaName}.[UnitTrackingCredentials] + SET [RevokedOn] = {_sqlConfiguration.ParameterNotation}RevokedOn + WHERE [UnitTrackingDeviceId] = {_sqlConfiguration.ParameterNotation}UnitTrackingDeviceId + AND [RevokedOn] IS NULL"; + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Transactions/UnitOfWork.cs b/Repositories/Resgrid.Repositories.DataRepository/Transactions/UnitOfWork.cs index 920b49c61..4fcaf3a39 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Transactions/UnitOfWork.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Transactions/UnitOfWork.cs @@ -2,6 +2,7 @@ using Resgrid.Model.Repositories.Queries; using System.Data.Common; using System.Threading; +using System.Threading.Tasks; namespace Resgrid.Repositories.DataRepository.Transactions @@ -39,6 +40,28 @@ public DbConnection CreateOrGetConnection() return Connection; } + public async Task CreateOrGetConnectionAsync(CancellationToken cancellationToken = default(CancellationToken)) + { + await _semaphore.WaitAsync(cancellationToken); + + try + { + if (Connection == null) + { + Connection = _connectionProvider.Create(); + await Connection.OpenAsync(cancellationToken); + + Transaction = Connection.BeginTransaction(); + } + + return Connection; + } + finally + { + _semaphore.Release(); + } + } + public void DiscardChanges() => Transaction?.Rollback(); public void Dispose() diff --git a/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingCredentialsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingCredentialsRepository.cs index e30134d93..8b97a8daa 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingCredentialsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingCredentialsRepository.cs @@ -70,6 +70,25 @@ public async Task GetBySecretHashAsync(string secretHash } } + public async Task RevokeAllByDeviceIdAsync(string unitTrackingDeviceId, DateTime revokedOn) + { + try + { + var parameters = new DynamicParametersExtension(); + parameters.Add("UnitTrackingDeviceId", unitTrackingDeviceId); + parameters.Add("RevokedOn", revokedOn); + var query = _queryFactory.GetUpdateQuery(null); + + return await WithConnectionAsync(connection => + connection.ExecuteAsync(query, parameters, _unitOfWork.Transaction)); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + private async Task WithConnectionAsync(Func> action) { if (_unitOfWork?.Connection != null) diff --git a/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingDevicesRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingDevicesRepository.cs index ca5872d03..675c3db88 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingDevicesRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/UnitTrackingDevicesRepository.cs @@ -39,9 +39,10 @@ public async Task> GetAllByUnitIdAsync(int depar parameters.Add("DepartmentId", departmentId); parameters.Add("UnitId", unitId); var query = _queryFactory.GetQuery(); + var transaction = _unitOfWork?.Transaction; return await WithConnectionAsync(connection => - connection.QueryAsync(query, parameters, _unitOfWork.Transaction)); + connection.QueryAsync(query, parameters, transaction)); } catch (Exception ex) { @@ -58,12 +59,13 @@ public async Task GetByProtocolIdentifierAsync(string protoc parameters.Add("ProtocolKey", protocolKey); parameters.Add("DeviceIdentifier", deviceIdentifier); var query = _queryFactory.GetQuery(); + var transaction = _unitOfWork?.Transaction; return await WithConnectionAsync(connection => connection.QuerySingleOrDefaultAsync( query, parameters, - _unitOfWork.Transaction)); + transaction)); } catch (Exception ex) { diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/MapLayersDocRepository.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/MapLayersDocRepository.cs index 1f28c010d..eb6d87c66 100644 --- a/Repositories/Resgrid.Repositories.NoSqlRepository/MapLayersDocRepository.cs +++ b/Repositories/Resgrid.Repositories.NoSqlRepository/MapLayersDocRepository.cs @@ -18,7 +18,7 @@ public async Task> GetAllMapLayersByDepartmentIdAsync(int departm using (var connection = new NpgsqlConnection(Config.DataConfig.DocumentConnectionString)) { await connection.OpenAsync(); - var mapLayersData = await connection.QueryAsync($"SELECT data FROM public.maplayers ml WHERE ml.departmentid = {departmentId};"); + var mapLayersData = await connection.QueryAsync("SELECT data FROM public.maplayers ml WHERE ml.departmentid = @departmentId;", new { departmentId }); if (mapLayersData != null && mapLayersData.Any()) { @@ -35,13 +35,16 @@ public async Task GetByIdAsync(string id) using (var connection = new NpgsqlConnection(Config.DataConfig.DocumentConnectionString)) { await connection.OpenAsync(); - var mapLayersData = await connection.QueryAsync($"SELECT data FROM public.maplayers ul WHERE ul.oid = '{id}';"); + var mapLayersData = await connection.QueryAsync("SELECT data FROM public.maplayers ul WHERE ul.oid = @id;", new { id }); if (mapLayersData != null) return mapLayersData.FirstOrDefault(); else { - var mapLayersData2 = await connection.QueryAsync($"SELECT data FROM public.maplayers ul WHERE ul.id = {id};"); + if (!int.TryParse(id, out var numericId)) + return null; + + var mapLayersData2 = await connection.QueryAsync("SELECT data FROM public.maplayers ul WHERE ul.id = @numericId;", new { numericId }); if (mapLayersData2 != null) return mapLayersData2.FirstOrDefault(); @@ -56,7 +59,7 @@ public async Task GetByOldIdAsync(string id) using (var connection = new NpgsqlConnection(Config.DataConfig.DocumentConnectionString)) { await connection.OpenAsync(); - var mapLayersData = await connection.QueryAsync($"SELECT data FROM public.maplayers ul WHERE ul.oid = '{id}';"); + var mapLayersData = await connection.QueryAsync("SELECT data FROM public.maplayers ul WHERE ul.oid = @id;", new { id }); if (mapLayersData != null) return mapLayersData.FirstOrDefault(); @@ -70,7 +73,8 @@ public async Task InsertAsync(MapLayer mapLayer) using (var connection = new NpgsqlConnection(Config.DataConfig.DocumentConnectionString)) { await connection.OpenAsync(); - var result = await connection.ExecuteScalarAsync($"INSERT INTO public.maplayers (departmentid, data) VALUES ({mapLayer.DepartmentId}, '{JsonConvert.SerializeObject(mapLayer)}') RETURNING id;"); + var result = await connection.ExecuteScalarAsync("INSERT INTO public.maplayers (departmentid, data) VALUES (@departmentId, CAST(@data AS jsonb)) RETURNING id;", + new { departmentId = mapLayer.DepartmentId, data = JsonConvert.SerializeObject(mapLayer) }); mapLayer.PgId = result; return mapLayer; @@ -83,8 +87,9 @@ public async Task UpdateAsync(MapLayer mapLayer) { await connection.OpenAsync(); - if (!string.IsNullOrWhiteSpace(mapLayer.PgId)) - await connection.ExecuteAsync($"UPDATE public.maplayers SET data = '{JsonConvert.SerializeObject(mapLayer)}' WHERE id = {mapLayer.PgId};"); + if (!string.IsNullOrWhiteSpace(mapLayer.PgId) && int.TryParse(mapLayer.PgId, out var pgId)) + await connection.ExecuteAsync("UPDATE public.maplayers SET data = CAST(@data AS jsonb) WHERE id = @pgId;", + new { data = JsonConvert.SerializeObject(mapLayer), pgId }); return mapLayer; diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs index af6990161..36ccd9eac 100644 --- a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs +++ b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs @@ -12,7 +12,7 @@ public class UnitLocationsMongoRepository : IUnitLocationsMongoRepository { private readonly IMongoCollection _collection; private readonly object _indexLock = new object(); - private Task _ensureIndexesTask; + private Task? _ensureIndexesTask; public UnitLocationsMongoRepository() { @@ -58,7 +58,24 @@ public Task EnsureIndexesAsync() { lock (_indexLock) { - return _ensureIndexesTask ??= CreateIndexesAsync(); + return _ensureIndexesTask ??= CreateIndexesAndClearOnFailureAsync(); + } + } + + private async Task CreateIndexesAndClearOnFailureAsync() + { + try + { + await CreateIndexesAsync(); + } + catch + { + lock (_indexLock) + { + _ensureIndexesTask = null; + } + + throw; } } diff --git a/Tests/Resgrid.Tests/Data/UnitTracking/Fixtures/traccar/v6.14.5/README.md b/Tests/Resgrid.Tests/Data/UnitTracking/Fixtures/traccar/v6.14.5/README.md new file mode 100644 index 000000000..7ab239609 --- /dev/null +++ b/Tests/Resgrid.Tests/Data/UnitTracking/Fixtures/traccar/v6.14.5/README.md @@ -0,0 +1,17 @@ +# Traccar v6.14.5 fixture + +- Adapter: `traccar-json-v1` +- Upstream version: Traccar `v6.14.5` +- Upstream commit: `5c5e710d5e357912f1b30561ed54bfd07a5d42f9` +- Fixture source: independently generated from Traccar's Apache-2.0 `PositionData`, + `Position`, and `Device` serialization contract +- Long-tail profile represented: SinoTrack ST-901 using Traccar's `h02` decoder +- Redistribution: generated for Resgrid; contains no captured customer, device, or vendor + data +- Expected result: one valid canonical position, unique ID `917000000000`, speed converted + from 10 knots to 5.14444 m/s, selected health/alarm attributes mapped, and unknown + attributes ignored + +This fixture proves the pinned JSON mapping and retry fingerprint only. It is not a +captured hardware packet and does not by itself promote the catalog profile beyond +`Candidate`. diff --git a/Tests/Resgrid.Tests/Data/UnitTracking/Fixtures/traccar/v6.14.5/sinotrack-st901-h02-position.json b/Tests/Resgrid.Tests/Data/UnitTracking/Fixtures/traccar/v6.14.5/sinotrack-st901-h02-position.json new file mode 100644 index 000000000..53b9a1186 --- /dev/null +++ b/Tests/Resgrid.Tests/Data/UnitTracking/Fixtures/traccar/v6.14.5/sinotrack-st901-h02-position.json @@ -0,0 +1,44 @@ +{ + "position": { + "id": 0, + "attributes": { + "sat": 11, + "hdop": 0.8, + "batteryLevel": 87, + "power": 13.6, + "rssi": 4, + "ignition": true, + "motion": true, + "alarm": "sos", + "unsupportedNestedTelemetry": { + "ignored": true + } + }, + "deviceId": 314, + "protocol": "h02", + "serverTime": "2026-07-24T18:42:52.001+00:00", + "deviceTime": "2026-07-24T18:42:50.000+00:00", + "fixTime": "2026-07-24T18:42:51.123+00:00", + "valid": true, + "latitude": 39.7392, + "longitude": -104.9903, + "altitude": 1608.2, + "speed": 10.0, + "course": 271.5, + "accuracy": 4.8, + "address": "Fixture address intentionally ignored" + }, + "device": { + "id": 314, + "attributes": { + "fixtureSource": "independently-generated" + }, + "name": "Long-tail SinoTrack ST-901 fixture", + "uniqueId": "917000000000", + "status": "online", + "model": "ST-901" + }, + "departmentId": 999, + "unitId": 999, + "unsupportedEnvelopeField": "ignored" +} diff --git a/Tests/Resgrid.Tests/Framework/SentryTransactionFilterTests.cs b/Tests/Resgrid.Tests/Framework/SentryTransactionFilterTests.cs index 71fa71ea3..9a5cda0bd 100644 --- a/Tests/Resgrid.Tests/Framework/SentryTransactionFilterTests.cs +++ b/Tests/Resgrid.Tests/Framework/SentryTransactionFilterTests.cs @@ -94,5 +94,58 @@ public void RedactCapabilityPath_RemovesTokenFromTraceValues() result.Should().Be("POST /api/v4/unit-trackers/c/[REDACTED]"); result.Should().NotContain(token); } + + [Test] + public void Filter_CapabilityPathsInTraceValues_RedactsRequestUrlAndTransactionName() + { + // Arrange + const string token = "rgtrk_prefix12_super-secret-capability"; + var transaction = new SentryTransaction( + $"POST /api/v4/unit-trackers/c/{token}", + "http.server") + { + Request = new SentryRequest + { + Url = $"https://resgrid.example/api/v4/unit-trackers/c/{token}" + } + }; + SetStatus(transaction, SpanStatus.InternalError); + + // Act + var result = SentryTransactionFilter.Filter(transaction); + + // Assert + result.Should().BeSameAs(transaction); + result.Name.Should().Be("POST /api/v4/unit-trackers/c/[REDACTED]"); + result.Request.Url.Should().Be("https://resgrid.example/api/v4/unit-trackers/c/[REDACTED]"); + result.Name.Should().NotContain(token); + result.Request.Url.Should().NotContain(token); + } + + [Test] + public void Filter_404WithCapabilityPathOnlyInTransactionName_RedactsName() + { + // Arrange + const string token = "rgtrk_prefix12_super-secret-capability"; + var transaction = new SentryTransaction( + $"POST /api/v4/unit-trackers/c/{token}", + "http.server"); + SetStatus(transaction, SpanStatus.NotFound); + + // Act + var result = SentryTransactionFilter.Filter(transaction); + + // Assert + result.Should().BeSameAs(transaction); + result.Name.Should().Be("POST /api/v4/unit-trackers/c/[REDACTED]"); + result.Name.Should().NotContain(token); + } + + private static void SetStatus(SentryTransaction transaction, SpanStatus status) + { + typeof(SentryTransaction) + .GetProperty(nameof(SentryTransaction.Status)) + .SetValue(transaction, status); + } } } diff --git a/Tests/Resgrid.Tests/Mocks/MockUnitOfWork.cs b/Tests/Resgrid.Tests/Mocks/MockUnitOfWork.cs index 999d5d7db..c6124befd 100644 --- a/Tests/Resgrid.Tests/Mocks/MockUnitOfWork.cs +++ b/Tests/Resgrid.Tests/Mocks/MockUnitOfWork.cs @@ -1,5 +1,7 @@ using Resgrid.Model.Repositories.Queries; using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; namespace Resgrid.Tests.Mocks { @@ -12,6 +14,7 @@ public sealed class MockUnitOfWork : IUnitOfWork public DbConnection Connection => null; public DbConnection CreateOrGetConnection() => null; + public Task CreateOrGetConnectionAsync(CancellationToken cancellationToken = default(CancellationToken)) => Task.FromResult(null); public void CommitChanges() { } public void DiscardChanges() { } public void Dispose() { } diff --git a/Tests/Resgrid.Tests/Resgrid.Tests.csproj b/Tests/Resgrid.Tests/Resgrid.Tests.csproj index b96d897dd..b648528f2 100644 --- a/Tests/Resgrid.Tests/Resgrid.Tests.csproj +++ b/Tests/Resgrid.Tests/Resgrid.Tests.csproj @@ -68,5 +68,8 @@ Always + + PreserveNewest + diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingAuthenticationServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingAuthenticationServiceTests.cs index 80bf01a23..c7c394526 100644 --- a/Tests/Resgrid.Tests/Services/UnitTrackingAuthenticationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/UnitTrackingAuthenticationServiceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using FluentAssertions; using Moq; @@ -49,9 +50,14 @@ public void TearDown() public void GenerateCredential_CreatesOneTimeTokenAndLowercaseHash() { var generated = _service.GenerateCredential(); + var tokenPrefix = $"rgtrk_{generated.KeyPrefix}_"; + var encodedSecret = generated.Token.Substring(tokenPrefix.Length); - generated.Token.Should().StartWith($"rgtrk_{generated.KeyPrefix}_"); + generated.Token.Should().StartWith(tokenPrefix); generated.KeyPrefix.Should().HaveLength(8); + generated.KeyPrefix.Should().MatchRegex("^[A-Za-z0-9_-]{8}$"); + encodedSecret.Should().MatchRegex("^[A-Za-z0-9_-]{43}$"); + generated.KeyPrefix.Should().NotBe(encodedSecret.Substring(0, 8)); generated.SecretHash.Should().MatchRegex("^[0-9a-f]{64}$"); _service.VerifySecret(generated.Token, generated.SecretHash).Should().BeTrue(); _service.VerifySecret(generated.Token + "x", generated.SecretHash).Should().BeFalse(); @@ -116,5 +122,44 @@ public async Task AuthenticateAsync_InactiveCredential_ReturnsNull(bool revoked, result.Should().BeNull(); } + + [Test] + public async Task GetActiveCredentialsForDeviceAsync_NullCacheResult_ReturnsEmptyCollection() + { + // Arrange + var device = new UnitTrackingDevice + { + UnitTrackingDeviceId = "device-1", + IsEnabled = true + }; + var cacheProvider = new Mock(); + _devicesRepository + .Setup(repository => repository.GetByIdAsync("device-1")) + .ReturnsAsync(device); + cacheProvider + .Setup(provider => provider.RetrieveAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns((string _, Func> fallback, TimeSpan _) => fallback()); + cacheProvider + .Setup(provider => provider.RetrieveAsync( + It.IsAny(), + It.IsAny>>>(), + It.IsAny())) + .ReturnsAsync((List)null); + var service = new UnitTrackingAuthenticationService( + _credentialsRepository.Object, + _devicesRepository.Object, + new UnitTrackingIdentifierService(), + cacheProvider.Object); + SystemBehaviorConfig.CacheEnabled = true; + + // Act + var result = await service.GetActiveCredentialsForDeviceAsync("device-1"); + + // Assert + result.Should().NotBeNull().And.BeEmpty(); + } } } diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs index f028524bc..40b2f8812 100644 --- a/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs @@ -30,6 +30,10 @@ public async Task GetProfilesAsync_FoundationProfiles_ExposeCertificationAndAdap .IsSelectable.Should().BeTrue(); profiles.Single(profile => profile.Key == "traccar-forwarder") .IsSelectable.Should().BeFalse(); + profiles.Single(profile => profile.Key == "traccar-forwarder") + .SupportedAuthModes.Should().Contain(UnitTrackingAuthMode.CapabilityPath); + profiles.Single(profile => profile.Key == "traccar-forwarder") + .SetupSummary.Should().Contain("v6.14.5"); profiles.Should().OnlyContain(profile => !string.IsNullOrWhiteSpace(profile.PayloadAdapterKey) && profile.SupportedAuthModes.Count > 0); diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingServiceTests.cs index 800065622..13ce6c633 100644 --- a/Tests/Resgrid.Tests/Services/UnitTrackingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/UnitTrackingServiceTests.cs @@ -159,6 +159,80 @@ await act.Should().ThrowAsync() Times.Never); } + [TestCase(null, null)] + [TestCase("", null)] + [TestCase(" ", null)] + [TestCase("10.0.0.0/8, 2001:db8::/32", "10.0.0.0/8,2001:db8::/32")] + public async Task CreateDeviceAsync_AllowedSourceCidrs_PersistsCanonicalValue( + string allowedSourceCidrs, + string expected) + { + // Arrange + var device = Device(); + device.AllowedSourceCidrs = allowedSourceCidrs; + + // Act + var result = await _service.CreateDeviceAsync(device, DepartmentId, UserId); + + // Assert + result.AllowedSourceCidrs.Should().Be(expected); + } + + [TestCase("not-a-cidr")] + [TestCase("10.0.0.1")] + [TestCase("10.0.0.0/33")] + [TestCase("2001:db8::/129")] + [TestCase("10.0.0.1/24")] + [TestCase("2001:0db8::/32")] + [TestCase(",")] + public async Task CreateDeviceAsync_InvalidAllowedSourceCidrs_RejectsBeforeSaving(string allowedSourceCidrs) + { + // Arrange + var device = Device(); + device.AllowedSourceCidrs = allowedSourceCidrs; + + // Act + Func act = () => _service.CreateDeviceAsync(device, DepartmentId, UserId); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("allowedSourceCidrs"); + _devicesRepository.Verify( + repository => repository.InsertAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Test] + public async Task UpdateDeviceAsync_InvalidAllowedSourceCidrs_RejectsBeforeSaving() + { + // Arrange + var existing = Device(); + var update = Device(); + update.AllowedSourceCidrs = "10.0.0.1/24"; + _devicesRepository + .Setup(repository => repository.GetByIdAsync(existing.UnitTrackingDeviceId)) + .ReturnsAsync(existing); + _credentialsRepository + .Setup(repository => repository.GetAllByDeviceIdAsync(existing.UnitTrackingDeviceId)) + .ReturnsAsync(new List()); + + // Act + Func act = () => _service.UpdateDeviceAsync(update, DepartmentId, UserId); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("allowedSourceCidrs"); + _devicesRepository.Verify( + repository => repository.UpdateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + [Test] public async Task CreateCredentialAsync_EnabledDevice_ReturnsTokenWithoutExposingStoredHash() { @@ -329,6 +403,58 @@ public async Task DisableDeviceAsync_ActiveDevice_RevokesCredentialsAtomically() audit.Type == AuditLogTypes.UnitTrackingDeviceDisabled); } + [Test] + public async Task UpdateDeviceAsync_DisableTransition_AppliesChangesAndRevokesCredentialsAtomically() + { + // Arrange + var existing = Device(); + var update = Device(); + update.DisplayName = " Updated Tracker "; + update.ManufacturerKey = " New Manufacturer "; + update.ModelKey = " New Model "; + update.TransportType = (int)UnitTrackingTransportType.NativeHttps; + update.ProtocolKey = " New Protocol "; + update.PayloadAdapterKey = " New Adapter "; + update.DeviceIdentifier = " updated-device-5678 "; + update.SecondaryIdentifier = " secondary-42 "; + update.IsEnabled = false; + update.SourcePriority = 25; + update.AllowedSourceCidrs = "10.0.0.0/8, 2001:db8::/32"; + update.FirmwareVersion = " 2.0.0 "; + var credentials = new List + { + Credential("credential-1", "hash-1") + }; + _devicesRepository + .Setup(repository => repository.GetByIdAsync(existing.UnitTrackingDeviceId)) + .ReturnsAsync(existing); + _credentialsRepository + .Setup(repository => repository.GetAllByDeviceIdAsync(existing.UnitTrackingDeviceId)) + .ReturnsAsync(credentials); + + // Act + var result = await _service.UpdateDeviceAsync(update, DepartmentId, UserId); + + // Assert + result.DisplayName.Should().Be("Updated Tracker"); + result.ManufacturerKey.Should().Be("new manufacturer"); + result.ModelKey.Should().Be("new model"); + result.TransportType.Should().Be((int)UnitTrackingTransportType.NativeHttps); + result.ProtocolKey.Should().Be("new protocol"); + result.PayloadAdapterKey.Should().Be("new adapter"); + result.DeviceIdentifier.Should().Be("UPDATED-DEVICE-5678"); + result.SecondaryIdentifier.Should().Be("SECONDARY-42"); + result.SourcePriority.Should().Be(25); + result.AllowedSourceCidrs.Should().Be("10.0.0.0/8,2001:db8::/32"); + result.FirmwareVersion.Should().Be("2.0.0"); + result.IsEnabled.Should().BeFalse(); + result.LastStatus.Should().Be((int)UnitTrackingDeviceStatus.Disabled); + credentials.Should().OnlyContain(credential => credential.RevokedOn.HasValue); + _unitOfWork.Verify(unitOfWork => unitOfWork.CommitChanges(), Times.Once); + _auditEvents.Should().ContainSingle(audit => + audit.Type == AuditLogTypes.UnitTrackingDeviceDisabled); + } + [Test] public async Task RebindDeviceAsync_NewUnit_SoftDeletesOldBindingAndCreatesReplacement() { @@ -352,11 +478,38 @@ public async Task RebindDeviceAsync_NewUnit_SoftDeletesOldBindingAndCreatesRepla result.UnitId.Should().Be(99); result.DeviceIdentifier.Should().Be(device.DeviceIdentifier); result.IsEnabled.Should().BeTrue(); + result.LastStatus.Should().Be((int)UnitTrackingDeviceStatus.NeverSeen); _unitOfWork.Verify(unitOfWork => unitOfWork.CommitChanges(), Times.Once); _auditEvents.Should().Contain(audit => audit.Type == AuditLogTypes.UnitTrackingDeviceDeleted); _auditEvents.Should().Contain(audit => audit.Type == AuditLogTypes.UnitTrackingDeviceCreated); } + [Test] + public async Task RebindDeviceAsync_DisabledSource_PreservesDisabledState() + { + // Arrange + var device = Device(); + device.IsEnabled = false; + device.LastStatus = (int)UnitTrackingDeviceStatus.Disabled; + _devicesRepository + .Setup(repository => repository.GetByIdAsync(device.UnitTrackingDeviceId)) + .ReturnsAsync(device); + _credentialsRepository + .Setup(repository => repository.GetAllByDeviceIdAsync(device.UnitTrackingDeviceId)) + .ReturnsAsync(new List()); + + // Act + var result = await _service.RebindDeviceAsync( + device.UnitTrackingDeviceId, + DepartmentId, + 99, + UserId); + + // Assert + result.IsEnabled.Should().BeFalse(); + result.LastStatus.Should().Be((int)UnitTrackingDeviceStatus.Disabled); + } + private static UnitTrackingDevice Device() { return new UnitTrackingDevice diff --git a/Tests/Resgrid.Tests/Web/Services/UnitTrackingIngressControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/UnitTrackingIngressControllerTests.cs index a0d87e9d3..46cf05243 100644 --- a/Tests/Resgrid.Tests/Web/Services/UnitTrackingIngressControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/UnitTrackingIngressControllerTests.cs @@ -132,6 +132,41 @@ public async Task PostPositions_ValidBearerPayload_ReturnsAcceptedWithoutTenantD It.IsAny()), Times.Once); } + [Test] + public async Task PostPositions_TraccarBinding_SelectsPinnedAdapterAndForwardsUniqueId() + { + _device.PayloadAdapterKey = "traccar-json-v1"; + SetBody(""" + { + "position": { + "id": 123, + "attributes": { "motion": true }, + "deviceId": 314, + "protocol": "h02", + "fixTime": "2026-07-24T18:42:51.123Z", + "valid": true, + "latitude": 39.7392, + "longitude": -104.9903, + "speed": 10.0 + }, + "device": { + "id": 314, + "uniqueId": "917000000000" + } + } + """); + + var result = await _controller.PostPositions("device-1"); + + result.Should().BeOfType(); + _ingressService.Verify(service => service.AcceptAsync( + It.Is(source => + source.ReportedDeviceIdentifier == "917000000000"), + It.Is>( + positions => positions.Count == 1), + It.IsAny()), Times.Once); + } + [Test] public async Task PostPositions_KnownEndpointWithInvalidCredential_ReturnsUnauthorized() { diff --git a/Tests/Resgrid.Tests/Web/Services/UnitTrackingJsonPayloadParserTests.cs b/Tests/Resgrid.Tests/Web/Services/UnitTrackingJsonPayloadParserTests.cs index 36b5eae35..22eb98ef8 100644 --- a/Tests/Resgrid.Tests/Web/Services/UnitTrackingJsonPayloadParserTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/UnitTrackingJsonPayloadParserTests.cs @@ -148,6 +148,127 @@ public async Task ParseAsync_GzipContentEncoding_ReturnsUnsupportedMediaType() result.Status.Should().Be(UnitTrackingPayloadParseStatus.UnsupportedMediaType); } + [Test] + public void Supports_KnownAdapters_RecognizesGenericAndPinnedTraccar() + { + _parser.Supports("resgrid-json-v1").Should().BeTrue(); + _parser.Supports(" TRACCAR-JSON-V1 ").Should().BeTrue(); + _parser.Supports("unknown-json-v1").Should().BeFalse(); + } + + [Test] + public async Task ParseAsync_TraccarFixture_MapsPinnedPositionAndSelectedAttributes() + { + var receivedOn = new DateTime(2026, 7, 24, 18, 42, 53, DateTimeKind.Utc); + var fixture = ReadFixture("sinotrack-st901-h02-position.json"); + + var result = await _parser.ParseAsync( + Request(fixture), + "traccar-json-v1", + receivedOn); + + result.Status.Should().Be(UnitTrackingPayloadParseStatus.Success); + result.ReportedDeviceIdentifier.Should().Be("917000000000"); + var position = result.Positions.Should().ContainSingle().Subject; + position.EventId.Should().StartWith("traccar:6.14.5:fingerprint:"); + position.TimestampUtc.Should().Be( + new DateTime(2026, 7, 24, 18, 42, 51, 123, DateTimeKind.Utc)); + position.ReceivedOnUtc.Should().Be(receivedOn); + position.Latitude.Should().Be(39.7392m); + position.Longitude.Should().Be(-104.9903m); + position.AccuracyMeters.Should().Be(4.8m); + position.AltitudeMeters.Should().Be(1608.2m); + position.SpeedMetersPerSecond.Should().Be(5.144440m); + position.HeadingDegrees.Should().Be(271.5m); + position.Satellites.Should().Be(11); + position.Hdop.Should().Be(0.8m); + position.BatteryPercent.Should().Be(87m); + position.ExternalPowerVolts.Should().Be(13.6m); + position.Ignition.Should().BeTrue(); + position.IsMoving.Should().BeTrue(); + position.AlarmCode.Should().Be("sos"); + position.SignalPercent.Should().BeNull( + "Traccar rssi has no stable percentage unit in the pinned contract"); + position.TimestampSource.Should().Be(TrackingTimestampSource.Device); + position.IsValidFix.Should().BeTrue(); + + var retry = await _parser.ParseAsync( + Request(fixture), + "traccar-json-v1", + receivedOn.AddSeconds(30)); + retry.Positions.Should().ContainSingle() + .Which.EventId.Should().Be(position.EventId); + } + + [Test] + public async Task ParseAsync_TraccarDeviceIdsDoNotMatch_RejectsPayload() + { + var request = Request(""" + { + "position": { + "deviceId": 42, + "fixTime": "2026-07-24T18:42:51.123Z", + "valid": true, + "latitude": 39.7392, + "longitude": -104.9903 + }, + "device": { + "id": 99, + "uniqueId": "917000000000" + } + } + """); + + var result = await _parser.ParseAsync( + request, + "traccar-json-v1", + DateTime.UtcNow); + + result.Status.Should().Be(UnitTrackingPayloadParseStatus.Invalid); + result.Errors.Should().Contain(error => error.Contains("must match")); + result.Positions.Should().BeEmpty(); + } + + [Test] + public async Task ParseAsync_TraccarHasNoValidTimestamp_RejectsPayload() + { + var request = Request(""" + { + "position": { + "deviceId": 42, + "fixTime": "not-a-time", + "valid": true, + "latitude": 39.7392, + "longitude": -104.9903 + }, + "device": { + "id": 42, + "uniqueId": "917000000000" + } + } + """); + + var result = await _parser.ParseAsync( + request, + "traccar-json-v1", + DateTime.UtcNow); + + result.Status.Should().Be(UnitTrackingPayloadParseStatus.Invalid); + result.Errors.Should().Contain(error => error.Contains("valid position")); + result.Positions.Should().BeEmpty(); + } + + private static string ReadFixture(string fileName) => + System.IO.File.ReadAllText( + Path.Combine( + TestContext.CurrentContext.TestDirectory, + "Data", + "UnitTracking", + "Fixtures", + "traccar", + "v6.14.5", + fileName)); + private static HttpRequest Request(string body) { var bytes = Encoding.UTF8.GetBytes(body); diff --git a/Tests/Resgrid.Tests/Web/User/UnitTrackingControllerTests.cs b/Tests/Resgrid.Tests/Web/User/UnitTrackingControllerTests.cs index 6cc881e92..413c12eb7 100644 --- a/Tests/Resgrid.Tests/Web/User/UnitTrackingControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/User/UnitTrackingControllerTests.cs @@ -241,6 +241,39 @@ public async Task Index_UnitOutsideDepartment_RedirectsToUnauthorized() It.IsAny()), Times.Never); } + [Test] + public async Task Edit_DisableTransition_UsesCredentialRevocationNotification() + { + // Arrange + _trackingService + .Setup(service => service.UpdateDeviceAsync( + It.IsAny(), + DepartmentId, + UserId, + It.IsAny())) + .ReturnsAsync((UnitTrackingDevice device, int departmentId, string userId, + CancellationToken cancellationToken) => device); + + // Act + var result = await _controller.Edit( + _device.UnitTrackingDeviceId, + new UnitTrackingEditorView + { + ProfileKey = _profile.Key, + DisplayName = "Updated Tracker", + DeviceIdentifier = _device.DeviceIdentifier, + IsEnabled = false, + SourcePriority = 25 + }, + CancellationToken.None); + + // Assert + result.Should().BeOfType() + .Which.ActionName.Should().Be(nameof(UnitTrackingController.Details)); + _controller.TempData["UnitTrackingSuccess"] + .Should().Be("TrackingBindingDisabledMessage"); + } + [Test] public async Task CreateCredential_ValidBearer_DisplaysSecretOnceAndDisablesCaching() { diff --git a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/TraccarJsonPayloadAdapter.cs b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/TraccarJsonPayloadAdapter.cs new file mode 100644 index 000000000..e4075a052 --- /dev/null +++ b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/TraccarJsonPayloadAdapter.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model; +using Resgrid.Model.Tracking; + +namespace Resgrid.Web.Services.ApplicationCore.UnitTracking +{ + internal static class TraccarJsonPayloadAdapter + { + internal const string PinnedVersion = "6.14.5"; + private const decimal KnotsToMetersPerSecond = 0.514444m; + + public static UnitTrackingPayloadParseResult Parse( + JObject root, + JsonSerializer serializer, + DateTime receivedOn) + { + TraccarPositionDataInput input; + try + { + input = root.ToObject(serializer); + } + catch (JsonException) + { + return Result(UnitTrackingPayloadParseStatus.Malformed); + } + + var errors = Validate(input); + if (errors.Count > 0) + return Invalid(errors); + + var position = input.Position; + var attributes = position.Attributes; + var timestamp = FirstTimestamp( + position.FixTime, + position.DeviceTime, + position.ServerTime); + var hasDeviceTimestamp = timestamp.HasValue; + var uniqueId = input.Device.UniqueId.Trim(); + + return new UnitTrackingPayloadParseResult + { + Status = UnitTrackingPayloadParseStatus.Success, + ReportedDeviceIdentifier = uniqueId, + Positions = new[] + { + new CanonicalTrackingPosition + { + EventId = CreateEventId(input, timestamp), + TimestampUtc = hasDeviceTimestamp + ? EnsureUtc(timestamp.Value) + : EnsureUtc(receivedOn), + ReceivedOnUtc = EnsureUtc(receivedOn), + Latitude = position.Latitude.Value, + Longitude = position.Longitude.Value, + AccuracyMeters = position.Accuracy, + AltitudeMeters = position.Altitude, + SpeedMetersPerSecond = position.SpeedKnots.HasValue + ? position.SpeedKnots.Value * KnotsToMetersPerSecond + : null, + HeadingDegrees = position.Course, + Satellites = Attribute(attributes, "sat"), + Hdop = Attribute(attributes, "hdop"), + BatteryPercent = Attribute(attributes, "batteryLevel"), + ExternalPowerVolts = Attribute(attributes, "power"), + Ignition = Attribute(attributes, "ignition"), + IsMoving = Attribute(attributes, "motion"), + AlarmCode = StringAttribute(attributes, "alarm"), + TimestampSource = hasDeviceTimestamp + ? TrackingTimestampSource.Device + : TrackingTimestampSource.Server, + IsValidFix = position.Valid ?? true + } + } + }; + } + + private static IReadOnlyCollection Validate(TraccarPositionDataInput input) + { + var errors = new List(); + if (input?.Position == null) + errors.Add("position is required."); + if (input?.Device == null) + errors.Add("device is required."); + if (errors.Count > 0) + return errors; + + if (!input.Position.DeviceId.HasValue || input.Position.DeviceId.Value <= 0) + errors.Add("position.deviceId is required."); + if (!input.Device.Id.HasValue || input.Device.Id.Value <= 0) + errors.Add("device.id is required."); + if (input.Position.DeviceId.HasValue && + input.Device.Id.HasValue && + input.Position.DeviceId.Value != input.Device.Id.Value) + errors.Add("The forwarded Traccar position and device identifiers must match."); + if (string.IsNullOrWhiteSpace(input.Device.UniqueId)) + errors.Add("device.uniqueId is required."); + if (!input.Position.Latitude.HasValue) + errors.Add("position.latitude is required."); + if (!input.Position.Longitude.HasValue) + errors.Add("position.longitude is required."); + if (!FirstTimestamp( + input.Position.FixTime, + input.Position.DeviceTime, + input.Position.ServerTime).HasValue) + errors.Add("A valid position fixTime, deviceTime, or serverTime is required."); + + return errors; + } + + private static string CreateEventId( + TraccarPositionDataInput input, + DateTime? timestamp) + { + if (input.Position.Id.HasValue && input.Position.Id.Value > 0) + return $"traccar:{PinnedVersion}:position:{input.Position.Id.Value}"; + + var position = input.Position; + var fingerprint = string.Join( + "|", + input.Device.UniqueId.Trim(), + position.DeviceId.Value.ToString(CultureInfo.InvariantCulture), + position.Protocol?.Trim() ?? string.Empty, + timestamp.HasValue + ? EnsureUtc(timestamp.Value).ToString("O", CultureInfo.InvariantCulture) + : string.Empty, + Invariant(position.Latitude), + Invariant(position.Longitude), + Invariant(position.Altitude), + Invariant(position.SpeedKnots), + Invariant(position.Course), + position.Valid.HasValue + ? position.Valid.Value ? "true" : "false" + : string.Empty); + + var hash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(fingerprint))) + .ToLowerInvariant(); + return $"traccar:{PinnedVersion}:fingerprint:{hash}"; + } + + private static DateTime? FirstTimestamp(params JToken[] values) + { + foreach (var value in values) + { + var timestamp = ParseTimestamp(value); + if (timestamp.HasValue) + return timestamp; + } + + return null; + } + + private static DateTime? ParseTimestamp(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + if (token.Type == JTokenType.Date) + return token.Value(); + + if (token.Type != JTokenType.String) + return null; + + return DateTimeOffset.TryParse( + token.Value(), + CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces | + DateTimeStyles.AssumeUniversal | + DateTimeStyles.AdjustToUniversal, + out var parsed) + ? parsed.UtcDateTime + : null; + } + + private static T? Attribute(JObject attributes, string name) + where T : struct + { + if (attributes == null || + !attributes.TryGetValue(name, StringComparison.Ordinal, out var value) || + value.Type == JTokenType.Null) + return null; + + try + { + return value.ToObject(); + } + catch (Exception ex) when ( + ex is JsonException || + ex is FormatException || + ex is InvalidCastException || + ex is OverflowException) + { + return null; + } + } + + private static string StringAttribute(JObject attributes, string name) + { + if (attributes == null || + !attributes.TryGetValue(name, StringComparison.Ordinal, out var value) || + value.Type != JTokenType.String) + return null; + + var result = value.Value(); + return string.IsNullOrWhiteSpace(result) ? null : result.Trim(); + } + + private static string Invariant(decimal? value) => + value?.ToString("G29", CultureInfo.InvariantCulture) ?? string.Empty; + + private static DateTime EnsureUtc(DateTime value) => + value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc) + }; + + private static UnitTrackingPayloadParseResult Result(UnitTrackingPayloadParseStatus status) => + new() { Status = status }; + + private static UnitTrackingPayloadParseResult Invalid(IReadOnlyCollection errors) => + new() + { + Status = UnitTrackingPayloadParseStatus.Invalid, + Errors = errors + }; + + private sealed class TraccarPositionDataInput + { + [JsonProperty("position")] + public TraccarPositionInput Position { get; set; } + + [JsonProperty("device")] + public TraccarDeviceInput Device { get; set; } + } + + private sealed class TraccarPositionInput + { + [JsonProperty("id")] + public long? Id { get; set; } + + [JsonProperty("attributes")] + public JObject Attributes { get; set; } + + [JsonProperty("deviceId")] + public long? DeviceId { get; set; } + + [JsonProperty("protocol")] + public string Protocol { get; set; } + + [JsonProperty("serverTime")] + public JToken ServerTime { get; set; } + + [JsonProperty("deviceTime")] + public JToken DeviceTime { get; set; } + + [JsonProperty("fixTime")] + public JToken FixTime { get; set; } + + [JsonProperty("valid")] + public bool? Valid { get; set; } + + [JsonProperty("latitude")] + public decimal? Latitude { get; set; } + + [JsonProperty("longitude")] + public decimal? Longitude { get; set; } + + [JsonProperty("altitude")] + public decimal? Altitude { get; set; } + + [JsonProperty("speed")] + public decimal? SpeedKnots { get; set; } + + [JsonProperty("course")] + public decimal? Course { get; set; } + + [JsonProperty("accuracy")] + public decimal? Accuracy { get; set; } + } + + private sealed class TraccarDeviceInput + { + [JsonProperty("id")] + public long? Id { get; set; } + + [JsonProperty("uniqueId")] + public string UniqueId { get; set; } + } + } +} diff --git a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingHttpAuthenticationService.cs b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingHttpAuthenticationService.cs index 52526f666..7048dd74d 100644 --- a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingHttpAuthenticationService.cs +++ b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingHttpAuthenticationService.cs @@ -130,24 +130,20 @@ private static PresentedCredential ParseBasic(string encoded) if (string.IsNullOrWhiteSpace(encoded)) return null; - try - { - var decodedBytes = Convert.FromBase64String(encoded); - var decoded = Encoding.UTF8.GetString(decodedBytes); - var separator = decoded.IndexOf(':'); - if (separator <= 0 || separator == decoded.Length - 1) - return null; + var decodedBytes = new byte[(encoded.Length * 3 / 4) + 3]; + if (!Convert.TryFromBase64String(encoded, decodedBytes, out var bytesWritten)) + return null; - return new PresentedCredential( - UnitTrackingAuthMode.Basic, - decoded.Substring(separator + 1), - decoded.Substring(0, separator), - null); - } - catch (FormatException) - { + var decoded = Encoding.UTF8.GetString(decodedBytes, 0, bytesWritten); + var separator = decoded.IndexOf(':'); + if (separator <= 0 || separator == decoded.Length - 1) return null; - } + + return new PresentedCredential( + UnitTrackingAuthMode.Basic, + decoded.Substring(separator + 1), + decoded.Substring(0, separator), + null); } private static bool MatchesEndpointCredential( diff --git a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingJsonPayloadParser.cs b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingJsonPayloadParser.cs index 0249b6b7b..2297dbd36 100644 --- a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingJsonPayloadParser.cs +++ b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingJsonPayloadParser.cs @@ -36,16 +36,26 @@ public sealed class UnitTrackingPayloadParseResult public class UnitTrackingJsonPayloadParser { + private const string ResgridJsonAdapterKey = "resgrid-json-v1"; + private const string TraccarJsonAdapterKey = "traccar-json-v1"; private static readonly UTF8Encoding StrictUtf8 = new(false, true); - public bool Supports(string payloadAdapterKey) => - string.Equals( - payloadAdapterKey?.Trim(), - "resgrid-json-v1", - StringComparison.OrdinalIgnoreCase); + public bool Supports(string payloadAdapterKey) + { + var normalized = payloadAdapterKey?.Trim(); + return string.Equals(normalized, ResgridJsonAdapterKey, StringComparison.OrdinalIgnoreCase) || + string.Equals(normalized, TraccarJsonAdapterKey, StringComparison.OrdinalIgnoreCase); + } + + public Task ParseAsync( + HttpRequest request, + DateTime receivedOn, + CancellationToken cancellationToken = default) => + ParseAsync(request, ResgridJsonAdapterKey, receivedOn, cancellationToken); public async Task ParseAsync( HttpRequest request, + string payloadAdapterKey, DateTime receivedOn, CancellationToken cancellationToken = default) { @@ -106,6 +116,18 @@ ex is DecoderFallbackException || MissingMemberHandling = MissingMemberHandling.Ignore }); + if (string.Equals( + payloadAdapterKey?.Trim(), + TraccarJsonAdapterKey, + StringComparison.OrdinalIgnoreCase)) + return TraccarJsonPayloadAdapter.Parse(root, serializer, receivedOn); + + if (!string.Equals( + payloadAdapterKey?.Trim(), + ResgridJsonAdapterKey, + StringComparison.OrdinalIgnoreCase)) + return Invalid("The configured payload adapter is not supported."); + UnitTrackingPositionInput[] inputs; string envelopeIdentifier = null; try @@ -235,7 +257,7 @@ private static async Task ReadBoundedBodyAsync( CancellationToken cancellationToken) { using var buffer = new MemoryStream(Math.Min(maximumBytes, 16 * 1024)); - var chunk = new byte[Math.Min(8192, maximumBytes + 1)]; + var chunk = new byte[Math.Min(8192, maximumBytes)]; var total = 0; while (true) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/AvatarsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/AvatarsController.cs index 3327135ae..4cea25b1b 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/AvatarsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/AvatarsController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Resgrid.Framework; using Resgrid.Model; @@ -17,7 +18,9 @@ namespace Resgrid.Web.Services.Controllers.v4 { /// - /// Used to interact with the user avatars (profile pictures) in the Resgrid system. The authentication header isn't required to access this method. + /// Used to interact with the user avatars (profile pictures) in the Resgrid system. Reading avatars does not + /// require authentication (they are embedded in pages via img tags), but uploading/cropping requires a + /// bearer token and callers can only modify their own avatar unless they are a department admin. /// [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] @@ -57,18 +60,39 @@ public async Task Get(string id, int? type) } [HttpPost("Upload")] + [Authorize(AuthenticationSchemes = OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task Upload([FromQuery] string id, int? type) { + var currentUserId = ClaimsAuthorizationHelper.GetUserId(); + var isDepartmentAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + + if (type == null || type.Value == (int)ImageTypes.Avatar) + { + if (id != currentUserId && !isDepartmentAdmin) + return Unauthorized(); + } + else if (!isDepartmentAdmin) + { + return Unauthorized(); + } + var img = HttpContext.Request.Form.Files.Count > 0 ? HttpContext.Request.Form.Files[0] : null; + if (img == null) + return BadRequest(); + // check for a valid mediatype if (!img.ContentType.StartsWith("image/")) return BadRequest(); + if (img.Length > 10000000) + return BadRequest(); + // load the image from the upload and generate a new filename //var image = Image.FromStream(img.OpenReadStream()); var extension = Path.GetExtension(img.FileName); @@ -121,9 +145,11 @@ public async Task Upload([FromQuery] string id, int? type) } [HttpPut("Crop")] + [Authorize(AuthenticationSchemes = OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task Crop([FromBody] CropRequest model) { @@ -131,6 +157,11 @@ public async Task Crop([FromBody] CropRequest model) var originalUri = new Uri(model.imgUrl); var originalId = originalUri.Query.Replace("?id=", ""); + var currentUserId = ClaimsAuthorizationHelper.GetUserId(); + + if (originalId != currentUserId && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + try { byte[] imgArray; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index c24d3bf9c..0d18581a7 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -253,7 +253,7 @@ public async Task> GetCallExtraData(int callId } if (call.DepartmentId != DepartmentId) - Unauthorized(); + return Unauthorized(); if (!await _authorizationService.CanUserViewCallAsync(UserId, callId)) return Unauthorized(); @@ -1500,7 +1500,7 @@ public async Task> GetCallHistory(int callId) } if (call.DepartmentId != DepartmentId) - Unauthorized(); + return Unauthorized(); call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelLocationController.cs b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelLocationController.cs index 2def321c0..9b69382e2 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelLocationController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelLocationController.cs @@ -13,6 +13,7 @@ using Resgrid.Providers.Claims; using Resgrid.Model.Events; using Resgrid.Web.Services.Models.v4.PersonnelLocation; +using Resgrid.Web.ServicesCore.Helpers; namespace Resgrid.Web.Services.Controllers.v4 { @@ -68,6 +69,9 @@ public async Task> SetPersonLocation(Person if (deps == null || !deps.Exists(x => x.DepartmentId == DepartmentId)) return Unauthorized(); + if (locationInput.UserId != UserId && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + if (!this.ModelState.IsValid) return BadRequest(); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStaffingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStaffingController.cs index c8d59c2f7..e1c86f95c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStaffingController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStaffingController.cs @@ -11,8 +11,10 @@ using System.Threading; using Resgrid.Framework; using System.Collections.Generic; +using System.Linq; using Resgrid.Web.Services.Models.v4.PersonnelStaffing; using Resgrid.Model.Helpers; +using Resgrid.Web.ServicesCore.Helpers; namespace Resgrid.Web.Services.Controllers.v4 { @@ -75,6 +77,19 @@ public async Task> GetCurrentStatffing(st if (string.IsNullOrEmpty(userId)) userId = UserId; + var member = await _departmentsService.GetDepartmentMemberAsync(userId, DepartmentId); + + if (member == null) + { + result.Data = null; + result.PageSize = 0; + result.Status = ResponseHelper.NotFound; + + ResponseHelper.PopulateV4ResponseData(result); + + return result; + } + var userState = await _userStateService.GetLastUserStateByUserIdAsync(userId); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); @@ -128,7 +143,8 @@ public async Task> SavePersonStaffing( if (!await _authorizationService.IsUserValidWithinLimitsAsync(userToSetStatusFor.UserId, DepartmentId)) return Unauthorized(); - // TODO: We need to check here if the user is a department admin, or the admin that the user is a part of + if (input.UserId != UserId && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); if (String.IsNullOrWhiteSpace(input.Note)) savedState = await _userStateService.CreateUserState(userToSetStatusFor.UserId, DepartmentId, int.Parse(input.Type), cancellationToken); @@ -171,6 +187,9 @@ public async Task> SavePersonsStaffin if (!int.TryParse(input.Type, out var typeVal)) return BadRequest("`Type` must be a valid integer."); + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && input.UserIds.Any(x => x != UserId)) + return Unauthorized(); + List logIds = new List(); foreach (var userId in input.UserIds) { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs index 9abf46010..97e5d80a7 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs @@ -12,7 +12,9 @@ using Resgrid.Web.Services.Models.v4.PersonnelStatuses; using Resgrid.Framework; using System.Collections.Generic; +using System.Linq; using Resgrid.Model.Helpers; +using Resgrid.Web.ServicesCore.Helpers; namespace Resgrid.Web.Services.Controllers.v4 { @@ -143,7 +145,8 @@ public async Task> SavePersonStatus(SavePer if (DepartmentId != userToSetStatusFor.DepartmentId) return Unauthorized(); - // TODO: We need to check here if the user is a department admin, or the admin that the user is a part of + if (input.UserId != UserId && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); string geolocation = null; if (!String.IsNullOrWhiteSpace(input.Latitude) && !String.IsNullOrWhiteSpace(input.Longitude)) @@ -212,6 +215,9 @@ public async Task> SavePersonsStatuses(S if (!ModelState.IsValid) return BadRequest(); + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && input.UserIds.Any(x => x != UserId)) + return Unauthorized(); + List logIds = new List(); foreach (var userId in input.UserIds) { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs index 0443c2900..4c052f999 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs @@ -36,6 +36,7 @@ public class UnitStatusController : V4AuthenticatedApiControllerbase private readonly IActionLogsService _actionLogsService; private readonly IMappingService _mappingService; private readonly IIncidentCommandService _incidentCommandService; + private readonly IDepartmentsService _departmentsService; public UnitStatusController( ICallsService callsService, @@ -44,7 +45,8 @@ public UnitStatusController( IDepartmentSettingsService departmentSettingsService, IActionLogsService actionLogsService, IMappingService mappingService, - IIncidentCommandService incidentCommandService + IIncidentCommandService incidentCommandService, + IDepartmentsService departmentsService ) { _callsService = callsService; @@ -54,6 +56,7 @@ IIncidentCommandService incidentCommandService _actionLogsService = actionLogsService; _mappingService = mappingService; _incidentCommandService = incidentCommandService; + _departmentsService = departmentsService; } #endregion Members and Constructors @@ -307,6 +310,13 @@ public async Task> GetUnitStatus(string unitId) // status update (TryParse also covers the null/empty check). if (!string.IsNullOrWhiteSpace(role.UserId) && int.TryParse(role.RoleId, out var unitStateRoleId)) { + // Only users that are members of this department can be placed in unit roles; + // otherwise status action logs could be written for arbitrary users. + var roleMember = await _departmentsService.GetDepartmentMemberAsync(role.UserId, DepartmentId); + + if (roleMember == null) + continue; + var unitRole = new UnitStateRole(); unitRole.UnitStateId = savedState.UnitStateId; unitRole.UserId = role.UserId; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingIngressController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingIngressController.cs index 096a03705..cf5074b1c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingIngressController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingIngressController.cs @@ -69,13 +69,17 @@ public async Task PostPositions(string unitTrackingDeviceId) } catch (Exception ex) { - return Unavailable(ex, "Unit tracking endpoint authentication is unavailable."); + return Unavailable(ex, "Unit tracking endpoint authentication is unavailable.", unitTrackingDeviceId); } if (authentication.Status == UnitTrackingHttpAuthenticationStatus.NotFound) return UnknownEndpointResponse(); if (authentication.Status != UnitTrackingHttpAuthenticationStatus.Authenticated) - return Unauthorized(); + { + var failedLimit = _rateLimiter.CheckUnknownEndpoint( + HttpContext.Connection.RemoteIpAddress?.ToString()); + return failedLimit.Allowed ? Unauthorized() : RateLimited(failedLimit); + } return await AcceptAsync(authentication.Source); } @@ -137,10 +141,24 @@ private async Task AcceptAsync( return Invalid("The configured payload adapter is not supported."); var receivedOn = DateTime.UtcNow; - var parsed = await _payloadParser.ParseAsync( - Request, - receivedOn, - HttpContext.RequestAborted); + UnitTrackingPayloadParseResult parsed; + try + { + parsed = await _payloadParser.ParseAsync( + Request, + source.Device.PayloadAdapterKey, + receivedOn, + HttpContext.RequestAborted); + } + catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Unavailable(ex, "Unit tracking payload parsing is unavailable.", source.Device?.UnitTrackingDeviceId); + } + var parseResponse = MapParseFailure(parsed); if (parseResponse != null) return parseResponse; @@ -167,7 +185,7 @@ private async Task AcceptAsync( } catch (Exception ex) { - return Unavailable(ex, "Unit tracking ingress is unavailable."); + return Unavailable(ex, "Unit tracking ingress is unavailable.", source.Device?.UnitTrackingDeviceId); } return result.Status switch @@ -222,9 +240,12 @@ private IActionResult RateLimited(UnitTrackingRateLimitResult limit) return StatusCode(StatusCodes.Status429TooManyRequests); } - private IActionResult Unavailable(Exception exception, string message) + private IActionResult Unavailable(Exception exception, string message, string deviceId = null) { - Logging.LogException(exception, message); + var context = string.IsNullOrWhiteSpace(deviceId) + ? message + : $"{message} Device: {deviceId}."; + Logging.LogException(exception, context, HttpContext?.TraceIdentifier); return StatusCode(StatusCodes.Status503ServiceUnavailable); } diff --git a/Web/Resgrid.Web.Services/Models/v4/UnitTracking/AllowedSourceCidrsAttribute.cs b/Web/Resgrid.Web.Services/Models/v4/UnitTracking/AllowedSourceCidrsAttribute.cs new file mode 100644 index 000000000..39d2db544 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/UnitTracking/AllowedSourceCidrsAttribute.cs @@ -0,0 +1,46 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Net; + +namespace Resgrid.Web.Services.Models.v4.UnitTracking +{ + [AttributeUsage(AttributeTargets.Property)] + public sealed class AllowedSourceCidrsAttribute : ValidationAttribute + { + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + if (value == null) + return ValidationResult.Success; + + if (value is not string cidrs || string.IsNullOrWhiteSpace(cidrs)) + return ValidationResult.Success; + + var entries = cidrs.Split( + ',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (entries.Length == 0) + return new ValidationResult( + "Allowed source CIDRs must contain at least one canonical CIDR entry.", + new[] { validationContext.MemberName }); + + foreach (var entry in entries) + { + var parts = entry.Split('/', 2, StringSplitOptions.None); + if (parts.Length != 2 || + !IPAddress.TryParse(parts[0], out var address) || + !int.TryParse(parts[1], out var prefixLength) || + prefixLength < 0 || + prefixLength > address.GetAddressBytes().Length * 8 || + !IPNetwork.TryParse(entry, out var network) || + !string.Equals(entry, network.ToString(), StringComparison.Ordinal)) + { + return new ValidationResult( + $"Allowed source CIDR entry '{entry}' is not a canonical IPv4 or IPv6 CIDR (for example 10.0.0.0/8).", + new[] { validationContext.MemberName }); + } + } + + return ValidationResult.Success; + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingAdminModels.cs b/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingAdminModels.cs index 149c042b3..0f5b23dc4 100644 --- a/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingAdminModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingAdminModels.cs @@ -96,6 +96,7 @@ public sealed class CreateUnitTrackingDeviceInput public int SourcePriority { get; set; } = 100; [MaxLength(2048)] + [AllowedSourceCidrs] public string AllowedSourceCidrs { get; set; } [MaxLength(128)] @@ -121,6 +122,7 @@ public sealed class UpdateUnitTrackingDeviceInput public int SourcePriority { get; set; } = 100; [MaxLength(2048)] + [AllowedSourceCidrs] public string AllowedSourceCidrs { get; set; } [MaxLength(128)] diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 8819ca800..a49d2d825 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -57,7 +57,9 @@ - Used to interact with the user avatars (profile pictures) in the Resgrid system. The authentication header isn't required to access this method. + Used to interact with the user avatars (profile pictures) in the Resgrid system. Reading avatars does not + require authentication (they are embedded in pages via img tags), but uploading/cropping requires a + bearer token and callers can only modify their own avatar unless they are a department admin. @@ -2191,6 +2193,11 @@ StatusInput object with the Status/Action to set. Returns HttpStatusCode Created if successful, BadRequest otherwise. + + + Administrative lifecycle and status operations for Unit hardware tracking bindings. + + User Defined Fields — allows departments to create custom fields for Calls, @@ -4037,52 +4044,6 @@ Is the user a group admin - - - UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a - function that is setting status for the current user. - - - - - The state/staffing level of the user to set for the user. - - - - - Note for the staffing level - - - - - The result object for a state/staffing level request. - - - - - The UserId GUID/UUID for the user state/staffing level being return - - - - - The full name of the user for the state/staffing level being returned - - - - - The current staffing level (state) type for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Staffing note for the User's staffing - - Input data to add a staffing schedule in the Resgrid system @@ -4188,6 +4149,52 @@ Note for this staffing schedule + + + UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a + function that is setting status for the current user. + + + + + The state/staffing level of the user to set for the user. + + + + + Note for the staffing level + + + + + The result object for a state/staffing level request. + + + + + The UserId GUID/UUID for the user state/staffing level being return + + + + + The full name of the user for the state/staffing level being returned + + + + + The current staffing level (state) type for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Staffing note for the User's staffing + + A resrouce in the system this could be a user or unit @@ -8254,374 +8261,204 @@ Identifier of the new npte - + - The result of getting all personnel filters for the system + A GPS location for a point in time of a specificed person - + - The Id value of the filter + PersonId of the person that the location is for - + - The type of the filter + The timestamp of the location in UTC - + - The filters name + GPS Latitude of the Person - + - Result containing all the data required to populate the New Call form + GPS Longitude of the Person - + - Response Data + GPS Latitude\Longitude Accuracy of the Person - + - Result that contains all the options available to filter personnel against compatible Resgrid APIs + GPS Altitude of the Person - + - Response Data + GPS Altitude Accuracy of the Person - + - Result containing all the data required to populate the New Call form + GPS Speed of the Person - + - Response Data + GPS Heading of the Person - + - Information about a User + A unit location in the Resgrid system - + - The UserId GUID/UUID for the user + Response Data - + - DepartmentId of the deparment the user belongs to + The information about a specific unit's location - + - Department specificed ID number for this user + Id of the Person - + - The Users First Name + The Timestamp for the location in UTC - + - The Users Last Name + GPS Latitude of the Person - + - The Users Email Address + GPS Longitude of the Person - + - The Users Mobile Telephone Number + GPS Latitude\Longitude Accuracy of the Person - + - GroupId the user is assigned to (0 for no group) + GPS Altitude of the Person - + - Name of the group the user is assigned to + GPS Altitude Accuracy of the Person - + - Enumeration/List of roles the user currently holds + GPS Speed of the Person - + - The current action/status type for the user + GPS Heading of the Person - + - The current action/status string for the user + The result of getting the current staffing for a user - + - The current action/status color hex string for the user + Response Data - + - The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + Information about a User staffing - + - The current action/status destination id for the user + The UserId GUID/UUID for the user status being return - + - The current action/status destination name for the user + DepartmentId of the deparment the user belongs to - + - The current staffing level (state) type for the user + The current staffing type for the user - + - The current staffing level (state) string for the user + The timestamp of the last staffing. This is converted UTC version of the timestamp. - + - The current staffing level (state) color hex string for the user + The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. - + - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + Note for this staffing - + - Users last known location + Saves (sets) and Personnel Staffing in the system, for a single user - + - Sorting weight for the user + UnitId of the apparatus that the state is being set for - + - User Defined Field values for this personnel record + The UnitStateType of the Unit - + - A GPS location for a point in time of a specificed person + The timestamp of the status event in UTC - + - PersonId of the person that the location is for + The timestamp of the status event in the local time of the device - + - The timestamp of the location in UTC + User provided note for this event - + - GPS Latitude of the Person + The event id used for queuing on mobile applications - + - GPS Longitude of the Person + Depicts a result after saving a person status - + - GPS Latitude\Longitude Accuracy of the Person - - - - - GPS Altitude of the Person - - - - - GPS Altitude Accuracy of the Person - - - - - GPS Speed of the Person - - - - - GPS Heading of the Person - - - - - A unit location in the Resgrid system - - - - - Response Data - - - - - The information about a specific unit's location - - - - - Id of the Person - - - - - The Timestamp for the location in UTC - - - - - GPS Latitude of the Person - - - - - GPS Longitude of the Person - - - - - GPS Latitude\Longitude Accuracy of the Person - - - - - GPS Altitude of the Person - - - - - GPS Altitude Accuracy of the Person - - - - - GPS Speed of the Person - - - - - GPS Heading of the Person - - - - - The result of getting the current staffing for a user - - - - - Response Data - - - - - Information about a User staffing - - - - - The UserId GUID/UUID for the user status being return - - - - - DepartmentId of the deparment the user belongs to - - - - - The current staffing type for the user - - - - - The timestamp of the last staffing. This is converted UTC version of the timestamp. - - - - - The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. - - - - - Note for this staffing - - - - - Saves (sets) and Personnel Staffing in the system, for a single user - - - - - UnitId of the apparatus that the state is being set for - - - - - The UnitStateType of the Unit - - - - - The timestamp of the status event in UTC - - - - - The timestamp of the status event in the local time of the device - - - - - User provided note for this event - - - - - The event id used for queuing on mobile applications - - - - - Depicts a result after saving a person status - - - - - Response Data + Response Data @@ -8927,114 +8764,284 @@ Response Data - + - Result containing all the data required to populate the New Call form + The result of getting all personnel filters for the system - + - Response Data + The Id value of the filter - + - Details of a protocol + The type of the filter - + - Protocol id + The filters name - + - Department id + Result containing all the data required to populate the New Call form - + - Name of the Protocol + Response Data - + - Protocol code + Result that contains all the options available to filter personnel against compatible Resgrid APIs - + - This this protocol disabled + Response Data - + - Protocol description + Result containing all the data required to populate the New Call form - + - Text of the protocol + Response Data - + - UTC date and time when the Protocol was created + Information about a User - + - UserId of the user who created the protocol + The UserId GUID/UUID for the user - + - UTC timestamp of when the Protocol was updated + DepartmentId of the deparment the user belongs to - + - Minimum triggering Weight of the Protocol + Department specificed ID number for this user - + - UserId that last updated the Protocol + The Users First Name - + - Triggers used to activate this Protocol + The Users Last Name - + - Attachments for this Protocol + The Users Email Address - + - Questions used to determine if this Protocol needs to be used or not + The Users Mobile Telephone Number - + - State type + GroupId the user is assigned to (0 for no group) - + - Result containing all the data required to populate the New Call form + Name of the group the user is assigned to - + - Response Data + Enumeration/List of roles the user currently holds - - Composite dashboard report (scalar totals, dense series, breakdowns). - + + + The current action/status type for the user + + + + + The current action/status string for the user + + + + + The current action/status color hex string for the user + + + + + The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + + + + + The current action/status destination id for the user + + + + + The current action/status destination name for the user + + + + + The current staffing level (state) type for the user + + + + + The current staffing level (state) string for the user + + + + + The current staffing level (state) color hex string for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Users last known location + + + + + Sorting weight for the user + + + + + User Defined Field values for this personnel record + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + + Details of a protocol + + + + + Protocol id + + + + + Department id + + + + + Name of the Protocol + + + + + Protocol code + + + + + This this protocol disabled + + + + + Protocol description + + + + + Text of the protocol + + + + + UTC date and time when the Protocol was created + + + + + UserId of the user who created the protocol + + + + + UTC timestamp of when the Protocol was updated + + + + + Minimum triggering Weight of the Protocol + + + + + UserId that last updated the Protocol + + + + + Triggers used to activate this Protocol + + + + + Attachments for this Protocol + + + + + Questions used to determine if this Protocol needs to be used or not + + + + + State type + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + Composite dashboard report (scalar totals, dense series, breakdowns). + Response Data @@ -10298,545 +10305,545 @@ Default constructor - + - Result that contains all the options available to filter units against compatible Resgrid APIs + Depicts a result after saving a unit status - + Response Data - + - A unit in the Resgrid system + Object inputs for setting a users Status/Action. If this object is used in an operation that sets + a status for the current user the UserId value in this object will be ignored. - + - Response Data + UnitId of the apparatus that the state is being set for - + - The information about a specific unit + The UnitStateType of the Unit - + - Id of the Unit + The Call/Station the unit is responding to - + - The Id of the department the unit is under + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). - + - Name of the Unit + The timestamp of the status event in UTC - + - Department assigned type for the unit + The timestamp of the status event in the local time of the device - + - Department assigned type id for the unit + User provided note for this event - + - Custom Statuses Set Id + GPS Latitude of the Unit - + - Station Id of the station housing the unit (0 means no station) + GPS Longitude of the Unit - + - Name of the station the unit is under + GPS Latitude\Longitude Accuracy of the Unit - + - Vehicle Identification Number for the unit + GPS Altitude of the Unit - + - Plate Number for the Unit + GPS Altitude Accuracy of the Unit - + - Is the unit 4-Wheel drive + GPS Speed of the Unit - + - Does the unit require a special permit to drive + GPS Heading of the Unit - + - Id number of the units current destionation (0 means no destination) + The event id used for queuing on mobile applications - + - The current status/state of the Unit + The accountability roles filed for this event - + - The Timestamp of the status + Role filled by a User on a Unit for an event - + - The units current Latitude + Id of the locally stored event - + - The units current Longitude + Local Event Id - + - Current user provide status note + UserId of the user filling the role - + - User Defined Field values for this unit + RoleId of the role being filled - + - Unit role information for roles on a unit + The name of the Role - + - Unit Role Id + Depicts a unit status in the Resgrid system. - + - User Id of the user in the role (could be null) + Response Data - + - Name of the Role + Depicts a unit's status - + - Name of the user in the role (could be null) + Unit Id - + - Multiple Unit infos Result + Units Name - + - Response Data + The Type of the Unit - + - Default constructor + Units current Status (State) - + - The information about a specific unit + CSS for status (for display) - + - Id of the Unit + CSS Style for status (for display) - + - The Id of the department the unit is under + Timestamp of this Unit State - + - Name of the Unit + Timestamp in Utc of this Unit State - + - Department assigned type for the unit + Destination Id (Station or Call) - + - Department assigned type id for the unit + Destination type (Station, Call, or POI). - + - Custom Statuses Set Id + Name of the Desination (Call or Station) - + - Station Id of the station housing the unit (0 means no station) + Destination address. - + - Name of the station the unit is under + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. - + - Vehicle Identification Number for the unit + Note for the State - + - Plate Number for the Unit + Latitude - + - Is the unit 4-Wheel drive + Longitude - + - Does the unit require a special permit to drive + Name of the Group the Unit is in - + - Id number of the units current destination (0 means no destination) + Id of the Group the Unit is in - + - Name of the units current destination (0 means no destination) + Unit statuses (states) - + - The current status/state of the Unit + Response Data - + - The current status/state of the Unit as a name + Default constructor - + - The current status/state of the Unit color + Result that contains all the options available to filter units against compatible Resgrid APIs - + - The Timestamp of the status + Response Data - + - The Timestamp of the status in UTC/GMT + A unit in the Resgrid system - + - The units current Latitude + Response Data - + - The units current Longitude + The information about a specific unit - + - Current user provide status note + Id of the Unit - + - Units Roles + The Id of the department the unit is under - + - Multiple Units Result + Name of the Unit - + - Response Data + Department assigned type for the unit - + - Default constructor + Department assigned type id for the unit - + - Depicts a result after saving a unit status + Custom Statuses Set Id - + - Response Data + Station Id of the station housing the unit (0 means no station) - + - Object inputs for setting a users Status/Action. If this object is used in an operation that sets - a status for the current user the UserId value in this object will be ignored. + Name of the station the unit is under - + - UnitId of the apparatus that the state is being set for + Vehicle Identification Number for the unit - + - The UnitStateType of the Unit + Plate Number for the Unit - + - The Call/Station the unit is responding to + Is the unit 4-Wheel drive - + - Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + Does the unit require a special permit to drive - + - The timestamp of the status event in UTC + Id number of the units current destionation (0 means no destination) - + - The timestamp of the status event in the local time of the device + The current status/state of the Unit - + - User provided note for this event + The Timestamp of the status - + - GPS Latitude of the Unit + The units current Latitude - + - GPS Longitude of the Unit + The units current Longitude - + - GPS Latitude\Longitude Accuracy of the Unit + Current user provide status note - + - GPS Altitude of the Unit + User Defined Field values for this unit - + - GPS Altitude Accuracy of the Unit + Unit role information for roles on a unit - + - GPS Speed of the Unit + Unit Role Id - + - GPS Heading of the Unit + User Id of the user in the role (could be null) - + - The event id used for queuing on mobile applications + Name of the Role - + - The accountability roles filed for this event + Name of the user in the role (could be null) - + - Role filled by a User on a Unit for an event + Multiple Unit infos Result - + - Id of the locally stored event + Response Data - + - Local Event Id + Default constructor - + - UserId of the user filling the role + The information about a specific unit - + - RoleId of the role being filled + Id of the Unit - + - The name of the Role + The Id of the department the unit is under - + - Depicts a unit status in the Resgrid system. + Name of the Unit - + - Response Data + Department assigned type for the unit - + - Depicts a unit's status + Department assigned type id for the unit - + - Unit Id + Custom Statuses Set Id - + - Units Name + Station Id of the station housing the unit (0 means no station) - + - The Type of the Unit + Name of the station the unit is under - + - Units current Status (State) + Vehicle Identification Number for the unit - + - CSS for status (for display) + Plate Number for the Unit - + - CSS Style for status (for display) + Is the unit 4-Wheel drive - + - Timestamp of this Unit State + Does the unit require a special permit to drive - + - Timestamp in Utc of this Unit State + Id number of the units current destination (0 means no destination) - + - Destination Id (Station or Call) + Name of the units current destination (0 means no destination) - + - Destination type (Station, Call, or POI). + The current status/state of the Unit - + - Name of the Desination (Call or Station) + The current status/state of the Unit as a name - + - Destination address. + The current status/state of the Unit color - + - Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not - suitable for programmatic branching; use as the - machine-readable discriminator instead. + The Timestamp of the status - + - Note for the State + The Timestamp of the status in UTC/GMT - + - Latitude + The units current Latitude - + - Longitude + The units current Longitude - + - Name of the Group the Unit is in + Current user provide status note - + - Id of the Group the Unit is in + Units Roles - + - Unit statuses (states) + Multiple Units Result - + Response Data - + Default constructor diff --git a/Web/Resgrid.Web/Areas/User/Apps/package-lock.json b/Web/Resgrid.Web/Areas/User/Apps/package-lock.json index 5673a18e3..6d420cd7f 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/package-lock.json +++ b/Web/Resgrid.Web/Areas/User/Apps/package-lock.json @@ -33,6 +33,7 @@ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -101,13 +102,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -117,6 +120,7 @@ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", "dev": true, + "peer": true, "dependencies": { "@babel/types": "^7.23.6", "@jridgewell/gen-mapping": "^0.3.2", @@ -874,7 +878,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1046,7 +1049,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -1316,6 +1318,7 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true, + "peer": true, "bin": { "jsesc": "bin/jsesc" }, @@ -1603,7 +1606,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1644,7 +1646,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -1883,7 +1884,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs index b97d73bb0..193b8881b 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs @@ -254,6 +254,9 @@ public async Task Edit(EditCalendarEntry model, CancellationToken if (ModelState.IsValid) { + if (!await _authorizationService.CanUserModifyCalendarEntryAsync(UserId, model.Item.CalendarItemId)) + return Unauthorized(); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); // Snapshot existing attendees before update so we can diff for notifications @@ -310,6 +313,9 @@ public async Task CreateCalendarItem([FromBody]CalendarItemJson item { if (ModelState.IsValid) { + if (item.CalendarItemId != 0 && !await _authorizationService.CanUserModifyCalendarEntryAsync(UserId, item.CalendarItemId)) + return Json(new { success = false, message = "You are not authorized to modify this calendar entry." }); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var timeZone = "Etc/UTC"; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index e5710b369..6fb0f0fa7 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -1323,12 +1323,14 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.En } [HttpGet] + [Authorize(Policy = ResgridResources.Department_Update)] public async Task GetAvailableNumbers(string country, string areaCode) { return Json(await _numbersService.GetAvailableNumbers(country, areaCode)); } [HttpGet] + [Authorize(Policy = ResgridResources.Department_Update)] public async Task ProvisionNumber(string msisdn, string country, CancellationToken cancellationToken) { if (!await _limitsService.CanDepartmentProvisionNumberAsync(DepartmentId)) @@ -1346,6 +1348,7 @@ public async Task ProvisionNumber(string msisdn, string country, } [HttpGet] + [Authorize(Policy = ResgridResources.Department_Update)] public async Task ProvisionDefaultNumberAsync(string country, string areaCode, CancellationToken cancellationToken) { if (!await _limitsService.CanDepartmentProvisionNumberAsync(DepartmentId)) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index dc26b2483..300f7aac4 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -2617,8 +2617,12 @@ public async Task GetScheduledCallsList() } [HttpPost] + [Authorize(Policy = ResgridResources.Call_View)] public async Task AttachCallFile(FileAttachInput model, IFormFile fileToUpload, CancellationToken cancellationToken) { + if (!await _authorizationService.CanUserEditCallAsync(UserId, model.CallId)) + return Unauthorized(); + if (fileToUpload == null || fileToUpload.Length <= 0) ModelState.AddModelError("fileToUpload", _dispatchLocalizer["AttachFileRequired"].Value); else @@ -2691,16 +2695,46 @@ public async Task GetTopActiveCalls() } [HttpGet] - public async Task GetCallImage(int callId, int attachmentId) + public async Task GetCallImage(int callId, int attachmentId, string token) { var callAttachment = await _callsService.GetCallAttachmentAsync(attachmentId); - if (callAttachment == null || callAttachment.CallId != callId) - return null; + if (callAttachment == null || callAttachment.CallId != callId || callAttachment.Call == null) + return NotFound(); + + // Authorized when the caller is an authenticated member of the call's department, + // or when presenting a valid per-image capability token (used by anonymous CallExportEx pages). + var isAuthorized = false; + + if (User?.Identity != null && User.Identity.IsAuthenticated && callAttachment.Call.DepartmentId == DepartmentId) + isAuthorized = true; + else if (IsValidCallImageToken(callId, attachmentId, token)) + isAuthorized = true; + + if (!isAuthorized) + return Unauthorized(); return File(callAttachment.Data, "image/jpeg"); } + private static bool IsValidCallImageToken(int callId, int attachmentId, string token) + { + if (String.IsNullOrWhiteSpace(token)) + return false; + + try + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(token)).Trim(); + var decrypted = SymmetricEncryption.Decrypt(decoded, Config.SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); + + return String.Equals(decrypted, $"{callId}|{attachmentId}", StringComparison.Ordinal); + } + catch + { + return false; + } + } + [HttpGet] [Authorize(Policy = ResgridResources.Call_View)] public async Task GetCallTypes() diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 7e3c5f8fd..892d4c139 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -1013,8 +1013,17 @@ public async Task SetCustomAction(int actionType, string note) return new StatusCodeResult((int)HttpStatusCode.NoContent); } + [Authorize(Policy = ResgridResources.Department_View)] public async Task SetCustomUserAction(string userId, int actionType) { + var member = await _departmentsService.GetDepartmentMemberAsync(userId, DepartmentId); + + if (member == null) + return Unauthorized(); + + if (userId != UserId && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + await _actionLogsService.SetUserActionAsync(userId, (await _departmentsService.GetDepartmentByUserIdAsync(UserId)).DepartmentId, actionType); return new StatusCodeResult((int)HttpStatusCode.OK); @@ -1023,6 +1032,14 @@ public async Task SetCustomUserAction(string userId, int actionTy [Authorize(Policy = ResgridResources.Department_View)] public async Task SetCustomStaffing(string userId, int staffingLevel) { + var member = await _departmentsService.GetDepartmentMemberAsync(userId, DepartmentId); + + if (member == null) + return Unauthorized(); + + if (userId != UserId && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + await _userStateService.CreateUserState(userId, DepartmentId, staffingLevel); return new StatusCodeResult((int)HttpStatusCode.NoContent); @@ -1031,6 +1048,9 @@ public async Task SetCustomStaffing(string userId, int staffingLe [Authorize(Policy = ResgridResources.Department_View)] public async Task ResetAllToStandingBy() { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + await _actionLogsService.SetActionForEntireDepartmentAsync((await _departmentsService.GetDepartmentByUserIdAsync(UserId)).DepartmentId, (int)ActionTypes.StandingBy, String.Empty); return RedirectToAction("Dashboard", "Home", new { area = "User" }); @@ -1039,6 +1059,14 @@ public async Task ResetAllToStandingBy() [Authorize(Policy = ResgridResources.Department_View)] public async Task ResetGroupToStandingBy(int groupId) { + var group = await _departmentGroupsService.GetGroupByIdAsync(groupId); + + if (group == null || group.DepartmentId != DepartmentId) + return Unauthorized(); + + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && !ClaimsAuthorizationHelper.IsUserGroupAdmin(groupId)) + return Unauthorized(); + await _actionLogsService.SetActionForDepartmentGroupAsync(groupId, (int)ActionTypes.StandingBy, String.Empty); return RedirectToAction("Dashboard", "Home", new { area = "User" }); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs index a852983fc..f311fad4c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs @@ -169,6 +169,9 @@ public async Task EditType(EditTypeView model) if (type == null) return RedirectToAction("ManageTypes"); + if (type.DepartmentId != DepartmentId) + return Unauthorized(); + type.Type = model.Type.Type; type.Description = model.Type.Description; type.ExpiresDays = model.Type.ExpiresDays; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs index 1df888a61..3226c0c83 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs @@ -249,7 +249,13 @@ public async Task EditLayer(EditLayerView model) if (ModelState.IsValid) { var newLayer = await _mappingService.GetMapLayersByIdAsync(model.Id); - newLayer.DepartmentId = DepartmentId; + + if (newLayer == null || newLayer.IsDeleted) + return NotFound(); + + if (newLayer.DepartmentId != DepartmentId) + return Unauthorized(); + newLayer.Name = model.Name; newLayer.Color = model.Color; newLayer.IsOnByDefault = model.IsOnByDefault; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs index 651f04f21..712d2eba4 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs @@ -331,6 +331,9 @@ public async Task MessageResponse(int recipientId, int response, if (messageRecipient != null) { + if (messageRecipient.UserId != UserId) + return Unauthorized(); + messageRecipient.Response = response.ToString(); messageRecipient.ReadOn = DateTime.UtcNow; messageRecipient.Note = HttpUtility.UrlDecode(note); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/NotificationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/NotificationsController.cs index 2ffdcec9a..ec1f3388f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/NotificationsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/NotificationsController.cs @@ -4,6 +4,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Resgrid.Model; @@ -126,6 +127,9 @@ public async Task Index() [HttpGet] public async Task New() { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + var model = new NotificationNewView(); model.Notification = new DepartmentNotification(); @@ -140,6 +144,9 @@ public async Task New() [HttpPost] public async Task New(NotificationNewView model, IFormCollection collection, CancellationToken cancellationToken) { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + ViewBag.Types = model.Type.ToSelectList(); model.Notification.DepartmentId = DepartmentId; model.Notification.EventType = (int)model.Type; @@ -237,8 +244,17 @@ public async Task New(NotificationNewView model, IFormCollection } [HttpGet] + [Authorize] public async Task Delete(int notificationId, CancellationToken cancellationToken) { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + var notifications = await _notificationService.GetNotificationsByDepartmentAsync(DepartmentId); + + if (notifications == null || !notifications.Any(x => x.DepartmentNotificationId == notificationId)) + return Unauthorized(); + await _notificationService.DeleteDepartmentNotificationByIdAsync(notificationId, cancellationToken); return RedirectToAction("Index"); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/OrdersController.cs b/Web/Resgrid.Web/Areas/User/Controllers/OrdersController.cs index 307c3e3ab..55db48e40 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/OrdersController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/OrdersController.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using System.Web; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; @@ -12,6 +13,7 @@ using Resgrid.Model; using Resgrid.Model.Services; using Resgrid.Web.Areas.User.Models.Orders; +using Resgrid.Web.Helpers; using Resgrid.WebCore.Areas.User.Models.Orders; using Resgrid.Model.Providers; @@ -90,6 +92,9 @@ public async Task Settings() [HttpPost] public async Task Settings(OrderSetttingsView model, IFormCollection form, CancellationToken cancellationToken) { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); var settings = await _resourceOrdersService.GetSettingsByDepartmentIdAsync(DepartmentId); @@ -237,26 +242,45 @@ public async Task New(NewOrderView model, IFormCollection form, C return View(model); } + [Authorize] public async Task View(int orderId) { var model = new ViewOrderView(); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); model.Order = await _resourceOrdersService.GetOrderByIdAsync(orderId); + if (model.Order == null) + return NotFound(); + + // Orders from other departments are only visible when they are open to mutual-aid fills + if (model.Order.DepartmentId != DepartmentId) + { + var visibleOrders = await _resourceOrdersService.GetOpenAvailableOrdersAsync(DepartmentId); + + if (visibleOrders == null || !visibleOrders.Any(x => x.ResourceOrderId == orderId)) + return Unauthorized(); + } + return View(model); } + [Authorize] public async Task AcceptFill(int fillId) { var model = new ViewOrderView(); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); var fill = await _resourceOrdersService.GetOrderFillByIdAsync(fillId); + + if (fill?.OrderItem?.Order == null || fill.OrderItem.Order.DepartmentId != DepartmentId) + return Unauthorized(); + await _resourceOrdersService.SetFillStatusAsync(fillId, UserId, true); return RedirectToAction("View", new {orderId = fill.OrderItem.Order.ResourceOrderId}); } + [Authorize] public async Task Fill(int orderId) { var model = new FillOrderView(); @@ -271,6 +295,7 @@ public async Task Fill(int orderId) } [HttpGet] + [Authorize] public async Task FillItem(int id, bool error, string errorMessage) { var model = new FillItemView(); @@ -282,6 +307,7 @@ public async Task FillItem(int id, bool error, string errorMessag } [HttpPost] + [Authorize] public async Task FillItem(FillItemInput data, CancellationToken cancellationToken) { var model = new FillItemView(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index 9f77f6697..7dfa1e3ec 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -1693,12 +1693,16 @@ orderby name ascending } [HttpGet] + [Authorize(Policy = ResgridResources.Personnel_View)] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public async Task GetMembersForRole(int id) { var role = await _personnelRolesService.GetRoleByIdAsync(id); - return Json(role.Users.Select(x => x.UserId).ToList()); + if (role == null || role.DepartmentId != DepartmentId) + return Unauthorized(); + + return Json(role.Users?.Select(x => x.UserId).ToList() ?? new List()); } [HttpGet] diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs index a1cc2d649..24dec6871 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs @@ -951,6 +951,9 @@ public async Task GetDepartmentCertificationTypes() [Authorize(Policy = ResgridResources.Profile_View)] public async Task ResetPasswordForUser(string userId) { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + var model = new ResetPasswordForUserView(); model.UserId = userId; @@ -969,6 +972,9 @@ public async Task ResetPasswordForUser(string userId) [ValidateAntiForgeryToken] public async Task ResetPasswordForUser(ResetPasswordForUserView model) { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + var department= await _departmentsService.GetDepartmentByIdAsync(DepartmentId); if (model.UserId == department.ManagingUserId) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProtocolsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProtocolsController.cs index c7dd4a98c..d817c1e28 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProtocolsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProtocolsController.cs @@ -209,6 +209,9 @@ public async Task Delete(int id) [Authorize(Policy = ResgridResources.Protocol_View)] public async Task GetProtocol(int id) { + if (!await _authorizationService.CanUserViewProtocolAsync(UserId, id)) + return Unauthorized(); + var template = await _protocolsService.GetProtocolByIdAsync(id); return Json(template); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs index 895f4ed2c..22f026497 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Config; using Resgrid.Model; using Resgrid.Model.Helpers; using Resgrid.Model.Services; @@ -546,8 +547,15 @@ public IActionResult FlaggedCallNotesReportParams(FlaggedCallNotesReportParams m [HttpGet] [AllowAnonymous] - public async Task InternalRunReport(int type, int departmentId) + public async Task InternalRunReport(int type, int departmentId, string key) { + var expectedKey = Config.SecurityConfig.InternalReportsToken; + + if (String.IsNullOrWhiteSpace(expectedKey) || String.IsNullOrWhiteSpace(key) || + !System.Security.Cryptography.CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(key), Encoding.UTF8.GetBytes(expectedKey))) + return Unauthorized(); + if (((ReportTypes)type) == ReportTypes.Staffing) { return View("StaffingReport", await CreateStaffingReportModel(departmentId)); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/TemplatesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/TemplatesController.cs index 01b03fe77..bdfa5e40e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/TemplatesController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/TemplatesController.cs @@ -221,6 +221,11 @@ public async Task EditCallNote(EditCallNoteModel model, Cancellat if (ModelState.IsValid) { + var existing = await _autofillsService.GetAutofillByIdAsync(model.Autofill.AutofillId); + + if (existing == null || existing.DepartmentId != DepartmentId) + return Unauthorized(); + model.Autofill.Type = (int)AutofillTypes.CallNote; model.Autofill.DepartmentId = DepartmentId; model.Autofill.AddedByUserId = UserId; @@ -262,10 +267,14 @@ public async Task DeleteCallNote(string id, CancellationToken can } [HttpGet] + [Authorize(Policy = ResgridResources.Call_View)] public async Task GetTemplate(int id) { var template = await _templatesService.GetCallQuickTemplateByIdAsync(id); + if (template == null || template.DepartmentId != DepartmentId) + return Unauthorized(); + return Json(template); } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/TrainingsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/TrainingsController.cs index 52254889a..edb31ed65 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/TrainingsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/TrainingsController.cs @@ -4,17 +4,20 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Services; +using Resgrid.Providers.Claims; using Resgrid.Web.Areas.User.Models.Training; using Resgrid.Web.Helpers; namespace Resgrid.Web.Areas.User.Controllers { [Area("User")] + [Authorize] public class TrainingsController : SecureBaseController { private readonly IDepartmentGroupsService _departmentGroupsService; @@ -31,6 +34,7 @@ public TrainingsController(IDepartmentGroupsService departmentGroupsService, IDe } [HttpGet] + [Authorize(Policy = ResgridResources.Training_View)] public async Task Index() { var model = new TrainingIndexModel(); @@ -40,7 +44,7 @@ public async Task Index() } [HttpGet] - + [Authorize(Policy = ResgridResources.Training_Create)] public async Task New() { var model = new NewTrainingModel(); @@ -52,6 +56,7 @@ public async Task New() } [HttpPost] + [Authorize(Policy = ResgridResources.Training_Create)] public async Task New(NewTrainingModel model, IFormCollection form, ICollection attachments) { model.Training.CreatedByUserId = UserId; @@ -243,6 +248,7 @@ public async Task View(int trainingId) } [HttpGet] + [Authorize(Policy = ResgridResources.Training_Update)] public async Task Edit(int trainingId) { var training = await _trainingService.GetTrainingByIdAsync(trainingId); @@ -270,6 +276,7 @@ public async Task Edit(int trainingId) [HttpPost] [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Training_Update)] public async Task Edit(int trainingId, EditTrainingModel model, IFormCollection form, ICollection attachments) { var existingTraining = await _trainingService.GetTrainingByIdAsync(trainingId); @@ -488,6 +495,14 @@ public async Task GetTrainingAttachment(int trainingAttachmentId) { var attachment = await _trainingService.GetTrainingAttachmentByIdAsync(trainingAttachmentId); + if (attachment == null) + return null; + + var training = await _trainingService.GetTrainingByIdAsync(attachment.TrainingId); + + if (training == null || training.DepartmentId != DepartmentId) + return null; + return new FileContentResult(attachment.Data, attachment.FileType) { FileDownloadName = attachment.FileName @@ -521,6 +536,9 @@ public async Task Quiz(ViewTrainingModel model, IFormCollection f if (training == null) return NotFound(); + if (training.DepartmentId != DepartmentId) + return Unauthorized(); + List questions = form.Keys .Where(k => k.StartsWith("question_")) .Select(k => { var s = k.Replace("question_", ""); return int.TryParse(s, out var v) ? (int?)v : null; }) @@ -548,6 +566,7 @@ public async Task Quiz(ViewTrainingModel model, IFormCollection f } [HttpGet] + [Authorize(Policy = ResgridResources.Training_Delete)] public async Task DeleteTraining(int trainingId) { var model = new ViewTrainingModel(); @@ -565,6 +584,7 @@ public async Task DeleteTraining(int trainingId) } [HttpGet] + [Authorize(Policy = ResgridResources.Training_Update)] public async Task ResetUserTraining(int trainingId, string userId) { var model = new ViewTrainingModel(); @@ -582,6 +602,7 @@ public async Task ResetUserTraining(int trainingId, string userId } [HttpGet] + [Authorize(Policy = ResgridResources.Training_View)] public async Task Report(int trainingId) { var model = new TrainingReportView(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/UnitTrackingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/UnitTrackingController.cs index c86538dd3..89b24725a 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/UnitTrackingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/UnitTrackingController.cs @@ -214,6 +214,7 @@ public async Task Edit( if (!ModelState.IsValid) return View(await BuildEditorAsync(context.Unit, model, cancellationToken)); + var disableRequested = context.Device.IsEnabled && !model.IsEnabled; ApplyUpdate(context.Device, model, profile); try { @@ -222,7 +223,10 @@ await _unitTrackingService.UpdateDeviceAsync( DepartmentId, UserId, cancellationToken); - TempData["UnitTrackingSuccess"] = _localizer["TrackingBindingUpdatedMessage"].Value; + TempData["UnitTrackingSuccess"] = _localizer[ + disableRequested + ? "TrackingBindingDisabledMessage" + : "TrackingBindingUpdatedMessage"].Value; return RedirectToAction(nameof(Details), new { id }); } catch (ArgumentException ex) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkshiftsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkshiftsController.cs index 8f046bf05..db6c17be2 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkshiftsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkshiftsController.cs @@ -107,11 +107,16 @@ public async Task Edit(string shiftId) } [HttpPost] - [Authorize(Policy = ResgridResources.Shift_Create)] + [Authorize(Policy = ResgridResources.Shift_Update)] public async Task Edit(NewWorkshiftView model, CancellationToken cancellationToken) { if (ModelState.IsValid) { + var existingShift = await _workShiftsService.GetWorkshiftByIdAsync(model.Shift.WorkshiftId); + + if (existingShift == null || existingShift.DepartmentId != DepartmentId) + return Unauthorized(); + model.Shift.DepartmentId = DepartmentId; model.Shift.AddedOn = DateTime.UtcNow; model.Shift.AddedById = UserId; diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml index cbeb05e39..055c23a81 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml @@ -545,7 +545,7 @@ - + } diff --git a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Details.cshtml index 4d952324c..48f4edfca 100644 --- a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Details.cshtml @@ -134,7 +134,7 @@ @Html.AntiForgeryToken()
- +
diff --git a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/_Editor.cshtml b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/_Editor.cshtml index a6683ffbd..5e9bb1f55 100644 --- a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/_Editor.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/_Editor.cshtml @@ -83,7 +83,7 @@ function updateProfileHelp() { const option = select.options[select.selectedIndex]; summary.textContent = option && option.dataset.summary - ? option.dataset.summary + " " + (option.dataset.retry || "") + ? `${option.dataset.summary} ${option.dataset.retry || ""}` : ""; } select.addEventListener("change", updateProfileHelp); diff --git a/Web/Resgrid.Web/wwwroot/js/ng/react-elements.css b/Web/Resgrid.Web/wwwroot/js/ng/react-elements.css index 981461d7d..41dc6f705 100644 --- a/Web/Resgrid.Web/wwwroot/js/ng/react-elements.css +++ b/Web/Resgrid.Web/wwwroot/js/ng/react-elements.css @@ -1,273 +1,273 @@ -@charset "UTF-8";.rg-element-root { - box-sizing: border-box; -} - -.rg-card { - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 6px; - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); -} - -.rg-loading { - display: flex; - min-height: 120px; - align-items: center; - justify-content: center; - color: #4b5563; - font-size: 14px; -} - -.rg-loading__stack { - display: inline-flex; - align-items: center; - gap: 12px; -} - -.rg-loading__spinner { - width: 18px; - height: 18px; - border-radius: 50%; - border: 2px solid rgba(59, 130, 246, 0.2); - border-top-color: #2563eb; - animation: rg-spin 0.8s linear infinite; -} - -.rg-error { - padding: 12px 14px; - color: #991b1b; - background: #fef2f2; - border: 1px solid #fecaca; - border-radius: 6px; - font-size: 13px; -} - -@keyframes rg-spin { - to { - transform: rotate(360deg); - } -} -.rg-map { - font-size: 13px; - color: #111827; -} - -.rg-map__toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 8px; -} - -.rg-map__toolbar-section { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px; -} - -.rg-map__search { - width: 220px; - max-width: 100%; - padding: 7px 10px; - border: 1px solid #cbd5e1; - border-radius: 6px; - outline: none; -} - -.rg-map__search:focus { - border-color: #2563eb; - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); -} - -.rg-map__checkbox, -.rg-map__layer-toggle { - display: inline-flex; - align-items: center; - gap: 6px; - font-weight: 400; -} - -.rg-map__message { - margin-bottom: 8px; -} - -.rg-map__viewport { - position: relative; - width: 100%; - min-height: 320px; - overflow: hidden; - border: 1px solid #d1d5db; - border-radius: 6px; - background: #f8fafc; -} - -.rg-map__canvas { - width: 100%; - height: 100%; -} - -.rg-map__canvas .leaflet-container { - width: 100%; - height: 100%; -} - -.rg-map__canvas .mapboxgl-map { - width: 100%; - height: 100%; -} - -.rg-map__layers { - position: absolute; - top: 12px; - right: 12px; - z-index: 450; - width: 240px; - max-width: calc(100% - 24px); - padding: 12px; -} - -.rg-map__layers-title { - margin-bottom: 8px; - font-weight: 600; -} - -.rg-map__layer-list { - display: flex; - flex-direction: column; - gap: 6px; -} - -.rg-map__layer-section + .rg-map__layer-section { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid #e5e7eb; -} - -.rg-map__layer-swatch { - width: 12px; - height: 12px; - border: 1px solid rgba(17, 24, 39, 0.2); - border-radius: 999px; - flex: 0 0 auto; -} - -.rg-map__overlay { - position: absolute; - inset: 0; - z-index: 500; - background: rgba(255, 255, 255, 0.72); -} - -.rg-map__footer { - display: flex; - justify-content: flex-end; - padding-top: 8px; - color: #6b7280; - font-size: 12px; -} - -.rg-map__tooltip { - padding: 2px 6px; - border: 1px solid #111827; - color: #111827; - background: #ffffff; - font-size: 11px; - font-weight: 600; -} - -.rg-map__tooltip::before { - border-top-color: #111827; -} - -.rg-map__marker { - display: inline-flex; - flex-direction: column; - align-items: center; - gap: 4px; - cursor: pointer; -} - -.rg-map__marker--poi { - position: relative; - width: 36px; - min-height: 48px; - justify-content: flex-start; -} - -.rg-map__marker-icon { - width: 32px; - height: 37px; - object-fit: contain; - pointer-events: none; -} - -.rg-map__poi-marker-wrapper { - background: transparent; - border: 0; -} - -.rg-map__poi-marker { - position: relative; - width: 36px; - height: 48px; -} - -.rg-map__poi-marker-shape { - width: 36px; - height: 48px; - fill: var(--rg-map-poi-color, #2563eb); - filter: drop-shadow(0 1px 2px rgba(17, 24, 39, 0.35)); -} - -.rg-map__poi-marker-icon { - position: absolute; - top: 10px; - left: 50%; - transform: translateX(-50%); - color: #ffffff; - font-size: 14px; - line-height: 1; - pointer-events: none; -} - -.rg-map__marker--poi .rg-map__poi-marker-shape { - width: 36px; - height: 48px; -} - -.rg-map__marker--poi .rg-map__poi-marker-icon { - top: 10px; -} - -.rg-map__marker-label { - max-width: 180px; - padding: 2px 6px; - border: 1px solid #111827; - border-radius: 4px; - color: #111827; - background: rgba(255, 255, 255, 0.96); - font-size: 11px; - font-weight: 600; - line-height: 1.2; - text-align: center; - white-space: nowrap; -} - -@media (max-width: 900px) { - .rg-map__toolbar { - align-items: stretch; - } - - .rg-map__layers { - position: static; - width: auto; - max-width: none; - margin: 12px; - } -} +@charset "UTF-8";.rg-element-root { + box-sizing: border-box; +} + +.rg-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 6px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); +} + +.rg-loading { + display: flex; + min-height: 120px; + align-items: center; + justify-content: center; + color: #4b5563; + font-size: 14px; +} + +.rg-loading__stack { + display: inline-flex; + align-items: center; + gap: 12px; +} + +.rg-loading__spinner { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid rgba(59, 130, 246, 0.2); + border-top-color: #2563eb; + animation: rg-spin 0.8s linear infinite; +} + +.rg-error { + padding: 12px 14px; + color: #991b1b; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + font-size: 13px; +} + +@keyframes rg-spin { + to { + transform: rotate(360deg); + } +} +.rg-map { + font-size: 13px; + color: #111827; +} + +.rg-map__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.rg-map__toolbar-section { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.rg-map__search { + width: 220px; + max-width: 100%; + padding: 7px 10px; + border: 1px solid #cbd5e1; + border-radius: 6px; + outline: none; +} + +.rg-map__search:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} + +.rg-map__checkbox, +.rg-map__layer-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 400; +} + +.rg-map__message { + margin-bottom: 8px; +} + +.rg-map__viewport { + position: relative; + width: 100%; + min-height: 320px; + overflow: hidden; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #f8fafc; +} + +.rg-map__canvas { + width: 100%; + height: 100%; +} + +.rg-map__canvas .leaflet-container { + width: 100%; + height: 100%; +} + +.rg-map__canvas .mapboxgl-map { + width: 100%; + height: 100%; +} + +.rg-map__layers { + position: absolute; + top: 12px; + right: 12px; + z-index: 450; + width: 240px; + max-width: calc(100% - 24px); + padding: 12px; +} + +.rg-map__layers-title { + margin-bottom: 8px; + font-weight: 600; +} + +.rg-map__layer-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.rg-map__layer-section + .rg-map__layer-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e5e7eb; +} + +.rg-map__layer-swatch { + width: 12px; + height: 12px; + border: 1px solid rgba(17, 24, 39, 0.2); + border-radius: 999px; + flex: 0 0 auto; +} + +.rg-map__overlay { + position: absolute; + inset: 0; + z-index: 500; + background: rgba(255, 255, 255, 0.72); +} + +.rg-map__footer { + display: flex; + justify-content: flex-end; + padding-top: 8px; + color: #6b7280; + font-size: 12px; +} + +.rg-map__tooltip { + padding: 2px 6px; + border: 1px solid #111827; + color: #111827; + background: #ffffff; + font-size: 11px; + font-weight: 600; +} + +.rg-map__tooltip::before { + border-top-color: #111827; +} + +.rg-map__marker { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 4px; + cursor: pointer; +} + +.rg-map__marker--poi { + position: relative; + width: 36px; + min-height: 48px; + justify-content: flex-start; +} + +.rg-map__marker-icon { + width: 32px; + height: 37px; + object-fit: contain; + pointer-events: none; +} + +.rg-map__poi-marker-wrapper { + background: transparent; + border: 0; +} + +.rg-map__poi-marker { + position: relative; + width: 36px; + height: 48px; +} + +.rg-map__poi-marker-shape { + width: 36px; + height: 48px; + fill: var(--rg-map-poi-color, #2563eb); + filter: drop-shadow(0 1px 2px rgba(17, 24, 39, 0.35)); +} + +.rg-map__poi-marker-icon { + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + color: #ffffff; + font-size: 14px; + line-height: 1; + pointer-events: none; +} + +.rg-map__marker--poi .rg-map__poi-marker-shape { + width: 36px; + height: 48px; +} + +.rg-map__marker--poi .rg-map__poi-marker-icon { + top: 10px; +} + +.rg-map__marker-label { + max-width: 180px; + padding: 2px 6px; + border: 1px solid #111827; + border-radius: 4px; + color: #111827; + background: rgba(255, 255, 255, 0.96); + font-size: 11px; + font-weight: 600; + line-height: 1.2; + text-align: center; + white-space: nowrap; +} + +@media (max-width: 900px) { + .rg-map__toolbar { + align-items: stretch; + } + + .rg-map__layers { + position: static; + width: auto; + max-width: none; + margin: 12px; + } +} .rbc-btn { color: inherit; @@ -1111,141 +1111,141 @@ button.rbc-input::-moz-focus-inner { width: 141px; } -/*# sourceMappingURL=react-big-calendar.css.map */.rg-shifts { - color: #111827; - font-size: 13px; -} - -.rg-shifts__calendar { - min-height: 760px; -} - -.rg-shifts .rbc-calendar { - min-height: 760px; -} - -.rg-shifts .rbc-toolbar { - gap: 12px; - margin-bottom: 18px; -} - -.rg-shifts .rbc-toolbar button { - border: 1px solid #2563eb; - border-radius: 4px; - background: #2563eb; - color: #ffffff; - padding: 6px 12px; -} - -.rg-shifts .rbc-toolbar button:hover, -.rg-shifts .rbc-toolbar button:focus { - border-color: #1d4ed8; - background: #1d4ed8; - color: #ffffff; -} - -.rg-shifts .rbc-toolbar button.rbc-active { - border-color: #1e40af; - background: #1e40af; - color: #ffffff; -} - -.rg-shifts .rbc-month-view, -.rg-shifts .rbc-time-view { - overflow: hidden; - border: 1px solid #d1d5db; - border-radius: 6px; -} - -.rg-shifts .rbc-header { - padding: 8px 0; - font-weight: 600; -} - -.rg-shifts .rbc-today { - background: #eff6ff; -} - -.rg-shifts .rbc-off-range-bg { - background: #f8fafc; -} - -.rg-shifts .rbc-event { - box-shadow: none; -} -.rg-omnibar { - display: flex; - flex-direction: column; - gap: 12px; - max-width: 640px; - color: #111827; - font-size: 13px; -} - -.rg-omnibar__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.rg-omnibar__title { - font-size: 18px; - font-weight: 600; -} - -.rg-omnibar__count { - color: #6b7280; - font-size: 12px; -} - -.rg-omnibar__input { - width: 100%; - padding: 10px 12px; - border: 1px solid #cbd5e1; - border-radius: 6px; - outline: none; -} - -.rg-omnibar__input:focus { - border-color: #2563eb; - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); -} - -.rg-omnibar__results { - display: flex; - flex-direction: column; - gap: 8px; -} - -.rg-omnibar__item { - display: flex; - width: 100%; - flex-direction: column; - gap: 4px; - padding: 12px; - border: 1px solid #dbe2ea; - border-radius: 6px; - background: #ffffff; - text-align: left; - transition: border-color 0.15s ease, background 0.15s ease; -} - -.rg-omnibar__item:hover, -.rg-omnibar__item--active { - border-color: #2563eb; - background: #eff6ff; -} - -.rg-omnibar__item-label { - font-size: 14px; - font-weight: 600; -} - -.rg-omnibar__item-description, -.rg-omnibar__empty { - color: #6b7280; -} +/*# sourceMappingURL=react-big-calendar.css.map */.rg-shifts { + color: #111827; + font-size: 13px; +} + +.rg-shifts__calendar { + min-height: 760px; +} + +.rg-shifts .rbc-calendar { + min-height: 760px; +} + +.rg-shifts .rbc-toolbar { + gap: 12px; + margin-bottom: 18px; +} + +.rg-shifts .rbc-toolbar button { + border: 1px solid #2563eb; + border-radius: 4px; + background: #2563eb; + color: #ffffff; + padding: 6px 12px; +} + +.rg-shifts .rbc-toolbar button:hover, +.rg-shifts .rbc-toolbar button:focus { + border-color: #1d4ed8; + background: #1d4ed8; + color: #ffffff; +} + +.rg-shifts .rbc-toolbar button.rbc-active { + border-color: #1e40af; + background: #1e40af; + color: #ffffff; +} + +.rg-shifts .rbc-month-view, +.rg-shifts .rbc-time-view { + overflow: hidden; + border: 1px solid #d1d5db; + border-radius: 6px; +} + +.rg-shifts .rbc-header { + padding: 8px 0; + font-weight: 600; +} + +.rg-shifts .rbc-today { + background: #eff6ff; +} + +.rg-shifts .rbc-off-range-bg { + background: #f8fafc; +} + +.rg-shifts .rbc-event { + box-shadow: none; +} +.rg-omnibar { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 640px; + color: #111827; + font-size: 13px; +} + +.rg-omnibar__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.rg-omnibar__title { + font-size: 18px; + font-weight: 600; +} + +.rg-omnibar__count { + color: #6b7280; + font-size: 12px; +} + +.rg-omnibar__input { + width: 100%; + padding: 10px 12px; + border: 1px solid #cbd5e1; + border-radius: 6px; + outline: none; +} + +.rg-omnibar__input:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} + +.rg-omnibar__results { + display: flex; + flex-direction: column; + gap: 8px; +} + +.rg-omnibar__item { + display: flex; + width: 100%; + flex-direction: column; + gap: 4px; + padding: 12px; + border: 1px solid #dbe2ea; + border-radius: 6px; + background: #ffffff; + text-align: left; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.rg-omnibar__item:hover, +.rg-omnibar__item--active { + border-color: #2563eb; + background: #eff6ff; +} + +.rg-omnibar__item-label { + font-size: 14px; + font-weight: 600; +} + +.rg-omnibar__item-description, +.rg-omnibar__empty { + color: #6b7280; +} .mapboxgl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgb(0 0 0/0)}.mapboxgl-canvas{left:0;position:absolute;top:0}.mapboxgl-map:-webkit-full-screen{height:100%;width:100%}.mapboxgl-canary{background-color:salmon}.mapboxgl-canvas-container.mapboxgl-interactive,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass{cursor:grab;-webkit-user-select:none;user-select:none}.mapboxgl-canvas-container.mapboxgl-interactive.mapboxgl-track-pointer{cursor:pointer}.mapboxgl-canvas-container.mapboxgl-interactive:active,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass:active{cursor:grabbing}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate .mapboxgl-canvas{touch-action:pan-x pan-y}.mapboxgl-canvas-container.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:pinch-zoom}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:none}.mapboxgl-ctrl-bottom,.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right,.mapboxgl-ctrl-left,.mapboxgl-ctrl-right,.mapboxgl-ctrl-top,.mapboxgl-ctrl-top-left,.mapboxgl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.mapboxgl-ctrl-top-left{left:0;top:0}.mapboxgl-ctrl-top{left:50%;top:0;transform:translateX(-50%)}.mapboxgl-ctrl-top-right{right:0;top:0}.mapboxgl-ctrl-right{right:0;top:50%;transform:translateY(-50%)}.mapboxgl-ctrl-bottom-right{bottom:0;right:0}.mapboxgl-ctrl-bottom{bottom:0;left:50%;transform:translateX(-50%)}.mapboxgl-ctrl-bottom-left{bottom:0;left:0}.mapboxgl-ctrl-left{left:0;top:50%;transform:translateY(-50%)}.mapboxgl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.mapboxgl-ctrl-top-left .mapboxgl-ctrl{float:left;margin:10px 0 0 10px}.mapboxgl-ctrl-top .mapboxgl-ctrl{float:left;margin:10px 0}.mapboxgl-ctrl-top-right .mapboxgl-ctrl{float:right;margin:10px 10px 0 0}.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl,.mapboxgl-ctrl-right .mapboxgl-ctrl{float:right;margin:0 10px 10px 0}.mapboxgl-ctrl-bottom .mapboxgl-ctrl{float:left;margin:10px 0}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl,.mapboxgl-ctrl-left .mapboxgl-ctrl{float:left;margin:0 0 10px 10px}.mapboxgl-ctrl-group{background:#fff;border-radius:4px}.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px #0000001a}@media (-ms-high-contrast:active){.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.mapboxgl-ctrl-group button{background-color:initial;border:0;box-sizing:border-box;cursor:pointer;display:block;height:32px;outline:none;overflow:hidden;padding:0;width:32px}.mapboxgl-ctrl-group button+button{border-top:1px solid #ddd}.mapboxgl-ctrl button .mapboxgl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (-ms-high-contrast:active){.mapboxgl-ctrl-icon{background-color:initial}.mapboxgl-ctrl-group button+button{border-top:1px solid ButtonText}}.mapboxgl-ctrl-attrib-button:focus,.mapboxgl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl button:disabled{cursor:not-allowed}.mapboxgl-ctrl button:disabled .mapboxgl-ctrl-icon{opacity:.25}.mapboxgl-ctrl-group button:first-child{border-radius:4px 4px 0 0}.mapboxgl-ctrl-group button:last-child{border-radius:0 0 4px 4px}.mapboxgl-ctrl-group button:only-child{border-radius:inherit}.mapboxgl-ctrl button:not(:disabled):hover{background-color:#eee}.mapboxgl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23999'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-arrow-up .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23333' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4.29289 11.7071C4.68342 12.0976 5.31658 12.0976 5.70711 11.7071L9 8.41421L12.2929 11.7071C12.6834 12.0976 13.3166 12.0976 13.7071 11.7071C14.0976 11.3166 14.0976 10.6834 13.7071 10.2929L9.70711 6.29289C9.31658 5.90237 8.68342 5.90237 8.29289 6.29289L4.29289 10.2929C3.90237 10.6834 3.90237 11.3166 4.29289 11.7071Z' fill='%23333333'/%3E%3C/svg%3E");background-size:18px 18px}.mapboxgl-ctrl button.mapboxgl-ctrl-arrow-down .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23333' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4.29289 6.29289C4.68342 5.90237 5.31658 5.90237 5.70711 6.29289L9 9.58579L12.2929 6.29289C12.6834 5.90237 13.3166 5.90237 13.7071 6.29289C14.0976 6.68342 14.0976 7.31658 13.7071 7.70711L9.70711 11.7071C9.31658 12.0976 8.68342 12.0976 8.29289 11.7071L4.29289 7.70711C3.90237 7.31658 3.90237 6.68342 4.29289 6.29289Z' fill='%23333333'/%3E%3C/svg%3E");background-size:18px 18px}.mapboxgl-ctrl button.mapboxgl-ctrl-indoor-toggle .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23333' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath d='M4.0017 3.0017L4.0017 15.0017L10.0017 15.0017V12.0017H12.0017V15.0017H14.0017L14.0017 3.0017C14.0097 2.86829 13.9894 2.73469 13.9419 2.60973C13.8945 2.48477 13.8211 2.37129 13.7266 2.27678C13.6321 2.18228 13.5186 2.10889 13.3937 2.06147C13.2687 2.01405 13.1351 1.99368 13.0017 2.0017L5.0017 2.0017C4.86829 1.99368 4.73469 2.01405 4.60973 2.06147C4.48477 2.10889 4.37129 2.18228 4.27678 2.27678C4.18228 2.37129 4.10889 2.48477 4.06147 2.60973C4.01405 2.73469 3.99368 2.86829 4.0017 3.0017ZM8.0017 14.0017H6.0017V12.0017H8.0017V14.0017ZM8.0017 10.0017H6.0017L6.0017 8.0017H8.0017V10.0017ZM8.0017 6.0017L6.0017 6.0017V4.0017H8.0017V6.0017ZM12.0017 10.0017H10.0017V8.0017H12.0017V10.0017ZM12.0017 6.0017H10.0017V4.0017L12.0017 4.0017V6.0017Z' fill='%23333333'/%3E%3C/svg%3E");background-size:18px 18px}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-indoor-toggle .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23fff' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath d='M4.0017 3.0017L4.0017 15.0017L10.0017 15.0017V12.0017H12.0017V15.0017H14.0017L14.0017 3.0017C14.0097 2.86829 13.9894 2.73469 13.9419 2.60973C13.8945 2.48477 13.8211 2.37129 13.7266 2.27678C13.6321 2.18228 13.5186 2.10889 13.3937 2.06147C13.2687 2.01405 13.1351 1.99368 13.0017 2.0017L5.0017 2.0017C4.86829 1.99368 4.73469 2.01405 4.60973 2.06147C4.48477 2.10889 4.37129 2.18228 4.27678 2.27678C4.18228 2.37129 4.10889 2.48477 4.06147 2.60973C4.01405 2.73469 3.99368 2.86829 4.0017 3.0017ZM8.0017 14.0017H6.0017V12.0017H8.0017V14.0017ZM8.0017 10.0017H6.0017L6.0017 8.0017H8.0017V10.0017ZM8.0017 6.0017L6.0017 6.0017V4.0017H8.0017V6.0017ZM12.0017 10.0017H10.0017V8.0017H12.0017V10.0017ZM12.0017 6.0017H10.0017V4.0017L12.0017 4.0017V6.0017Z' fill='%23333333'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-indoor-toggle .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23000' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath d='M4.0017 3.0017L4.0017 15.0017L10.0017 15.0017V12.0017H12.0017V15.0017H14.0017L14.0017 3.0017C14.0097 2.86829 13.9894 2.73469 13.9419 2.60973C13.8945 2.48477 13.8211 2.37129 13.7266 2.27678C13.6321 2.18228 13.5186 2.10889 13.3937 2.06147C13.2687 2.01405 13.1351 1.99368 13.0017 2.0017L5.0017 2.0017C4.86829 1.99368 4.73469 2.01405 4.60973 2.06147C4.48477 2.10889 4.37129 2.18228 4.27678 2.27678C4.18228 2.37129 4.10889 2.48477 4.06147 2.60973C4.01405 2.73469 3.99368 2.86829 4.0017 3.0017ZM8.0017 14.0017H6.0017V12.0017H8.0017V14.0017ZM8.0017 10.0017H6.0017L6.0017 8.0017H8.0017V10.0017ZM8.0017 6.0017L6.0017 6.0017V4.0017H8.0017V6.0017ZM12.0017 10.0017H10.0017V8.0017H12.0017V10.0017ZM12.0017 6.0017H10.0017V4.0017L12.0017 4.0017V6.0017Z' fill='%23333333'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23aaa'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-waiting .mapboxgl-ctrl-icon{animation:mapboxgl-spin 2s linear infinite}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23999'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23000'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23666'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}}@keyframes mapboxgl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}a.mapboxgl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='0.3' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='0.9' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}a.mapboxgl-ctrl-logo.mapboxgl-compact{width:23px}@media (-ms-high-contrast:active){a.mapboxgl-ctrl-logo{background-color:initial;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){a.mapboxgl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23fff' stroke-width='3' fill='%23fff'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23000'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E")}}.mapboxgl-ctrl.mapboxgl-ctrl-attrib{background-color:#ffffff80;margin:0;padding:0 5px}@media screen{.mapboxgl-ctrl-attrib.mapboxgl-compact{background-color:#fff;border-radius:12px;box-sizing:initial;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.mapboxgl-ctrl-attrib.mapboxgl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show,.mapboxgl-ctrl-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show,.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-inner{display:none}.mapboxgl-ctrl-attrib-button{background-color:#ffffff80;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-left .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-top-left .mapboxgl-ctrl-attrib-button{left:0}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-inner{display:block}.mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button{background-color:#0000000d}.mapboxgl-ctrl-bottom-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;right:0}.mapboxgl-ctrl-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{right:0}.mapboxgl-ctrl-top-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{right:0;top:0}.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{left:0;top:0}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;left:0}.mapboxgl-ctrl-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{left:0}}@media screen and (-ms-high-contrast:active){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' fill='%23fff'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (-ms-high-contrast:black-on-white){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.mapboxgl-ctrl-attrib a{color:#000000bf;text-decoration:none}.mapboxgl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.mapboxgl-ctrl-attrib .mapbox-improve-map{font-weight:700;margin-left:2px}.mapboxgl-attrib-empty{display:none}.mapboxgl-ctrl-scale{background-color:#ffffffbf;border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px;white-space:nowrap}.mapboxgl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.mapboxgl-popup-anchor-top,.mapboxgl-popup-anchor-top-left,.mapboxgl-popup-anchor-top-right{flex-direction:column}.mapboxgl-popup-anchor-bottom,.mapboxgl-popup-anchor-bottom-left,.mapboxgl-popup-anchor-bottom-right{flex-direction:column-reverse}.mapboxgl-popup-anchor-left{flex-direction:row}.mapboxgl-popup-anchor-right{flex-direction:row-reverse}.mapboxgl-popup-tip{border:10px solid #0000;height:0;width:0;z-index:1}.mapboxgl-popup-anchor-top .mapboxgl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.mapboxgl-popup-anchor-left .mapboxgl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.mapboxgl-popup-anchor-right .mapboxgl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.mapboxgl-popup-close-button{background-color:initial;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.mapboxgl-popup-close-button:hover{background-color:#eee}.mapboxgl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px #0000001a;padding:10px 10px 15px;pointer-events:auto;position:relative}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-content{border-top-left-radius:0}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-content{border-top-right-radius:0}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-content{border-bottom-left-radius:0}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-content{border-bottom-right-radius:0}.mapboxgl-popup-track-pointer{display:none}.mapboxgl-popup-track-pointer *{pointer-events:none;user-select:none}.mapboxgl-map:hover .mapboxgl-popup-track-pointer{display:flex}.mapboxgl-map:active .mapboxgl-popup-track-pointer{display:none}.mapboxgl-marker{left:0;opacity:1;position:absolute;top:0;transition:opacity .2s;will-change:transform}.mapboxgl-user-location-dot,.mapboxgl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.mapboxgl-user-location-dot:before{animation:mapboxgl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.mapboxgl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px #00000059;box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading{height:0;width:0}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after,.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before{border-bottom:7.5px solid #4aa1eb;content:"";position:absolute}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before{border-left:7.5px solid #0000;transform:translateY(-28px) skewY(-20deg)}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after{border-right:7.5px solid #0000;transform:translate(7.5px,-28px) skewY(20deg)}@keyframes mapboxgl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.mapboxgl-user-location-dot-stale{background-color:#aaa}.mapboxgl-user-location-dot-stale:after{display:none}.mapboxgl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.mapboxgl-crosshair,.mapboxgl-crosshair .mapboxgl-interactive,.mapboxgl-crosshair .mapboxgl-interactive:active{cursor:crosshair}.mapboxgl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}@media print{.mapbox-improve-map{display:none}}.mapboxgl-scroll-zoom-blocker,.mapboxgl-touch-pan-blocker{align-items:center;background:#000000b3;color:#fff;display:flex;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;height:100%;justify-content:center;left:0;opacity:0;pointer-events:none;position:absolute;text-align:center;top:0;transition:opacity .75s ease-in-out;transition-delay:1s;width:100%}.mapboxgl-scroll-zoom-blocker-show,.mapboxgl-touch-pan-blocker-show{opacity:1;transition:opacity .1s ease-in-out}.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page,.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page .mapboxgl-canvas{touch-action:pan-x pan-y}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button{font-size:16px;font-weight:700;text-align:center}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button-selected{background-color:#ccc;color:#000}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button-selected:hover{background-color:#ccc}/* required styles */ .leaflet-pane, diff --git a/Web/Resgrid.Web/wwwroot/js/ng/react-elements.js b/Web/Resgrid.Web/wwwroot/js/ng/react-elements.js index d4ac6fd2d..07fee4806 100644 --- a/Web/Resgrid.Web/wwwroot/js/ng/react-elements.js +++ b/Web/Resgrid.Web/wwwroot/js/ng/react-elements.js @@ -1,2 +1,2 @@ -import "./chunks/elements-BK6xE-JG.min.js"; +import "./chunks/elements-33RDS2SM.min.js"; //# sourceMappingURL=react-elements.js.map diff --git a/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs index 7d23ef9fe..6531bfc47 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs @@ -38,9 +38,10 @@ public async Task> Process(ReportDeliveryQueueItem item) if (ConfigHelper.CanTransmit(item.Department.DepartmentId)) { var client = new RestClient(Config.SystemBehaviorConfig.ResgridBaseUrl); - var request = new RestRequest("User/Reports/InternalRunReport", Method.Get); - request.AddParameter("type", item.ScheduledTask.Data); - request.AddParameter("departmentId", item.Department.DepartmentId); + var request = new RestRequest("User/Reports/InternalRunReport", Method.Get); + request.AddParameter("type", item.ScheduledTask.Data); + request.AddParameter("departmentId", item.Department.DepartmentId); + request.AddParameter("key", Config.SecurityConfig.InternalReportsToken); var response = await client.ExecuteAsync(request); diff --git a/Workers/Resgrid.Workers.Framework/Logic/UnitLocationQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/UnitLocationQueueLogic.cs index 6f9e43013..46b974189 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/UnitLocationQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/UnitLocationQueueLogic.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using Autofac; +using Resgrid.Framework; using Resgrid.Model.Services; using Resgrid.Model.Events; using Resgrid.Model; @@ -12,59 +13,70 @@ public class UnitLocationQueueLogic { public static async Task ProcessUnitLocationQueueItem(UnitLocationEvent unitLocationEvent, CancellationToken cancellationToken = default(CancellationToken)) { - if (unitLocationEvent == null) - throw new ArgumentNullException(nameof(unitLocationEvent)); + try + { + if (unitLocationEvent == null) + throw new ArgumentNullException(nameof(unitLocationEvent)); - if (unitLocationEvent.UnitId <= 0) - throw new InvalidOperationException("A Unit location queue event must identify a Unit."); + if (unitLocationEvent.UnitId <= 0) + throw new InvalidOperationException("A Unit location queue event must identify a Unit."); - if (unitLocationEvent.DepartmentId <= 0) - throw new InvalidOperationException("A Unit location queue event must identify a Department."); + if (unitLocationEvent.DepartmentId <= 0) + throw new InvalidOperationException("A Unit location queue event must identify a Department."); - if (unitLocationEvent.IsValidFix == false) - return true; + if (unitLocationEvent.IsValidFix == false) + { + Logging.LogInfo($"UnitLocationQueueLogic dropping invalid fix for UnitId {unitLocationEvent.UnitId}, EventId {unitLocationEvent.EventId}."); + return true; + } - if (!unitLocationEvent.Latitude.HasValue || !unitLocationEvent.Longitude.HasValue) - throw new InvalidOperationException("A valid Unit location queue event must contain latitude and longitude."); + if (!unitLocationEvent.Latitude.HasValue || !unitLocationEvent.Longitude.HasValue) + throw new InvalidOperationException("A valid Unit location queue event must contain latitude and longitude."); - var unitService = Bootstrapper.GetKernel().Resolve(); - var timestamp = unitLocationEvent.Timestamp == default - ? unitLocationEvent.ReceivedOn ?? DateTime.UtcNow - : unitLocationEvent.Timestamp; - var unitLocation = new UnitsLocation - { - EventId = unitLocationEvent.EventId, - DepartmentId = unitLocationEvent.DepartmentId, - UnitId = unitLocationEvent.UnitId, - Timestamp = timestamp, - ReceivedOn = unitLocationEvent.ReceivedOn ?? timestamp, - SourceType = unitLocationEvent.SourceType, - SourceId = unitLocationEvent.SourceId, - SourcePriority = unitLocationEvent.SourcePriority, - TransportType = unitLocationEvent.TransportType, - ProtocolKey = unitLocationEvent.ProtocolKey, - IsValidFix = unitLocationEvent.IsValidFix ?? true, - Latitude = unitLocationEvent.Latitude.Value, - Longitude = unitLocationEvent.Longitude.Value, - Accuracy = unitLocationEvent.Accuracy, - Altitude = unitLocationEvent.Altitude, - AltitudeAccuracy = unitLocationEvent.AltitudeAccuracy, - Speed = unitLocationEvent.Speed, - Heading = unitLocationEvent.Heading, - Satellites = unitLocationEvent.Satellites, - Hdop = unitLocationEvent.Hdop, - BatteryPercent = unitLocationEvent.BatteryPercent, - ExternalPowerVolts = unitLocationEvent.ExternalPowerVolts, - SignalPercent = unitLocationEvent.SignalPercent, - Ignition = unitLocationEvent.Ignition, - IsMoving = unitLocationEvent.IsMoving, - AlarmCode = unitLocationEvent.AlarmCode, - TimestampSource = unitLocationEvent.TimestampSource - }; + var unitService = Bootstrapper.GetKernel().Resolve(); + var timestamp = unitLocationEvent.Timestamp == default + ? unitLocationEvent.ReceivedOn ?? DateTime.UtcNow + : unitLocationEvent.Timestamp; + var unitLocation = new UnitsLocation + { + EventId = unitLocationEvent.EventId, + DepartmentId = unitLocationEvent.DepartmentId, + UnitId = unitLocationEvent.UnitId, + Timestamp = timestamp, + ReceivedOn = unitLocationEvent.ReceivedOn ?? timestamp, + SourceType = unitLocationEvent.SourceType, + SourceId = unitLocationEvent.SourceId, + SourcePriority = unitLocationEvent.SourcePriority, + TransportType = unitLocationEvent.TransportType, + ProtocolKey = unitLocationEvent.ProtocolKey, + IsValidFix = unitLocationEvent.IsValidFix, + Latitude = unitLocationEvent.Latitude.Value, + Longitude = unitLocationEvent.Longitude.Value, + Accuracy = unitLocationEvent.Accuracy, + Altitude = unitLocationEvent.Altitude, + AltitudeAccuracy = unitLocationEvent.AltitudeAccuracy, + Speed = unitLocationEvent.Speed, + Heading = unitLocationEvent.Heading, + Satellites = unitLocationEvent.Satellites, + Hdop = unitLocationEvent.Hdop, + BatteryPercent = unitLocationEvent.BatteryPercent, + ExternalPowerVolts = unitLocationEvent.ExternalPowerVolts, + SignalPercent = unitLocationEvent.SignalPercent, + Ignition = unitLocationEvent.Ignition, + IsMoving = unitLocationEvent.IsMoving, + AlarmCode = unitLocationEvent.AlarmCode, + TimestampSource = unitLocationEvent.TimestampSource + }; - await unitService.AddUnitLocationAsync(unitLocation, unitLocationEvent.DepartmentId, cancellationToken); + await unitService.AddUnitLocationAsync(unitLocation, unitLocationEvent.DepartmentId, cancellationToken); - return true; + return true; + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } } } } From 065169e9505bb17c378f62e407238c53fe951817 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 10:36:40 -0700 Subject: [PATCH 3/8] RG-T127 PR#438 fixes --- .../Migrations/M0101_AddUnitTrackingPg.cs | 30 ++++++++++++++++--- .../MapLayersDocRepository.cs | 2 +- .../UnitTracking/TraccarJsonPayloadAdapter.cs | 4 +++ .../Controllers/v4/AvatarsController.cs | 5 +++- .../User/Controllers/DispatchController.cs | 15 +++++++++- .../Areas/User/Controllers/HomeController.cs | 6 ++-- .../User/Controllers/ReportsController.cs | 3 +- .../User/Views/Dispatch/CallExportEx.cshtml | 2 +- Web/Resgrid.Web/Helpers/LinkHelper.cs | 17 ++++++++++- .../Logic/ReportDeliveryLogic.cs | 2 +- 10 files changed, 72 insertions(+), 14 deletions(-) diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs index 81c3da26c..cd013fce7 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs @@ -2,7 +2,10 @@ namespace Resgrid.Providers.MigrationsPg.Migrations { - [Migration(101)] + // TransactionBehavior.None is required for CREATE UNIQUE INDEX CONCURRENTLY on the live units + // table (it cannot run inside a transaction). Statements self-commit, so all creates below are + // guarded with existence checks to stay safe on a re-run after a partial apply. + [Migration(101, TransactionBehavior.None)] public class M0101_AddUnitTrackingPg : Migration { private const string DevicesTable = "unittrackingdevices"; @@ -39,11 +42,30 @@ public override void Up() .WithColumn("updatedbyuserid").AsCustom("citext").Nullable() .WithColumn("updatedon").AsDateTime2().Nullable(); + // ALTER TABLE ADD CONSTRAINT UNIQUE takes a SHARE ROW EXCLUSIVE lock on the live units + // table and fails if duplicates exist. Pre-validate duplicates, build the index online + // with CONCURRENTLY, then attach it as a constraint (brief lock) so it can still serve + // as the foreign key target below. + Execute.Sql(@" + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM units GROUP BY departmentid, unitid HAVING COUNT(*) > 1) THEN + RAISE EXCEPTION 'uq_units_departmentid_unitid: duplicate (departmentid, unitid) rows exist in units; remove them before rerunning this migration'; + END IF; + END $$;"); + + if (!Schema.Table("units").Index("uq_units_departmentid_unitid").Exists()) + { + Execute.Sql(@" + CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_units_departmentid_unitid + ON units (departmentid, unitid);"); + } + if (!Schema.Table("units").Constraint("uq_units_departmentid_unitid").Exists()) { - Create.UniqueConstraint("uq_units_departmentid_unitid") - .OnTable("units") - .Columns("departmentid", "unitid"); + Execute.Sql(@" + ALTER TABLE units + ADD CONSTRAINT uq_units_departmentid_unitid UNIQUE USING INDEX uq_units_departmentid_unitid;"); } Create.ForeignKey("fk_unittrackingdevices_units_department_unit") diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/MapLayersDocRepository.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/MapLayersDocRepository.cs index eb6d87c66..82e544516 100644 --- a/Repositories/Resgrid.Repositories.NoSqlRepository/MapLayersDocRepository.cs +++ b/Repositories/Resgrid.Repositories.NoSqlRepository/MapLayersDocRepository.cs @@ -37,7 +37,7 @@ public async Task GetByIdAsync(string id) await connection.OpenAsync(); var mapLayersData = await connection.QueryAsync("SELECT data FROM public.maplayers ul WHERE ul.oid = @id;", new { id }); - if (mapLayersData != null) + if (mapLayersData != null && mapLayersData.Any()) return mapLayersData.FirstOrDefault(); else { diff --git a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/TraccarJsonPayloadAdapter.cs b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/TraccarJsonPayloadAdapter.cs index e4075a052..210244f5a 100644 --- a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/TraccarJsonPayloadAdapter.cs +++ b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/TraccarJsonPayloadAdapter.cs @@ -102,8 +102,12 @@ private static IReadOnlyCollection Validate(TraccarPositionDataInput inp errors.Add("device.uniqueId is required."); if (!input.Position.Latitude.HasValue) errors.Add("position.latitude is required."); + else if (input.Position.Latitude.Value < -90m || input.Position.Latitude.Value > 90m) + errors.Add("position.latitude must be within the range [-90, 90]."); if (!input.Position.Longitude.HasValue) errors.Add("position.longitude is required."); + else if (input.Position.Longitude.Value < -180m || input.Position.Longitude.Value > 180m) + errors.Add("position.longitude must be within the range [-180, 180]."); if (!FirstTimestamp( input.Position.FixTime, input.Position.DeviceTime, diff --git a/Web/Resgrid.Web.Services/Controllers/v4/AvatarsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/AvatarsController.cs index 4cea25b1b..2c1827c2e 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/AvatarsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/AvatarsController.cs @@ -154,7 +154,10 @@ public async Task Upload([FromQuery] string id, int? type) public async Task Crop([FromBody] CropRequest model) { // extract original image ID and generate a new filename for the cropped result - var originalUri = new Uri(model.imgUrl); + if (model == null || string.IsNullOrWhiteSpace(model.imgUrl) || + !Uri.TryCreate(model.imgUrl, UriKind.Absolute, out var originalUri)) + return BadRequest(); + var originalId = originalUri.Query.Replace("?id=", ""); var currentUserId = ClaimsAuthorizationHelper.GetUserId(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 300f7aac4..b44d598a8 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -2726,8 +2726,21 @@ private static bool IsValidCallImageToken(int callId, int attachmentId, string t { var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(token)).Trim(); var decrypted = SymmetricEncryption.Decrypt(decoded, Config.SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); + var parts = decrypted.Split('|'); - return String.Equals(decrypted, $"{callId}|{attachmentId}", StringComparison.Ordinal); + if (parts.Length != 3) + return false; + + if (!Int32.TryParse(parts[0], out var tokenCallId) || tokenCallId != callId) + return false; + + if (!Int32.TryParse(parts[1], out var tokenAttachmentId) || tokenAttachmentId != attachmentId) + return false; + + if (!Int64.TryParse(parts[2], out var expiryEpoch) || expiryEpoch < DateTimeOffset.UtcNow.ToUnixTimeSeconds()) + return false; + + return true; } catch { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 892d4c139..1699e48ed 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -1059,14 +1059,14 @@ public async Task ResetAllToStandingBy() [Authorize(Policy = ResgridResources.Department_View)] public async Task ResetGroupToStandingBy(int groupId) { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && !ClaimsAuthorizationHelper.IsUserGroupAdmin(groupId)) + return Unauthorized(); + var group = await _departmentGroupsService.GetGroupByIdAsync(groupId); if (group == null || group.DepartmentId != DepartmentId) return Unauthorized(); - if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && !ClaimsAuthorizationHelper.IsUserGroupAdmin(groupId)) - return Unauthorized(); - await _actionLogsService.SetActionForDepartmentGroupAsync(groupId, (int)ActionTypes.StandingBy, String.Empty); return RedirectToAction("Dashboard", "Home", new { area = "User" }); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs index 22f026497..e213cef2a 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs @@ -547,9 +547,10 @@ public IActionResult FlaggedCallNotesReportParams(FlaggedCallNotesReportParams m [HttpGet] [AllowAnonymous] - public async Task InternalRunReport(int type, int departmentId, string key) + public async Task InternalRunReport(int type, int departmentId) { var expectedKey = Config.SecurityConfig.InternalReportsToken; + var key = Request.Headers["X-Internal-Reports-Token"].ToString(); if (String.IsNullOrWhiteSpace(expectedKey) || String.IsNullOrWhiteSpace(key) || !System.Security.Cryptography.CryptographicOperations.FixedTimeEquals( diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml index 055c23a81..77ebd4f12 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml @@ -545,7 +545,7 @@ - +
} diff --git a/Web/Resgrid.Web/Helpers/LinkHelper.cs b/Web/Resgrid.Web/Helpers/LinkHelper.cs index f20c1b3b5..3dfd9f62f 100644 --- a/Web/Resgrid.Web/Helpers/LinkHelper.cs +++ b/Web/Resgrid.Web/Helpers/LinkHelper.cs @@ -1,7 +1,22 @@ -namespace Resgrid.Web.Helpers +using System; +using System.Text; +using Resgrid.Config; +using Resgrid.Framework; + +namespace Resgrid.Web.Helpers { public static class LinkHelper { + private const int CallImageTokenLifetimeHours = 24; + + public static string GenerateCallImageToken(int callId, int attachmentId) + { + var payload = $"{callId}|{attachmentId}|{DateTimeOffset.UtcNow.AddHours(CallImageTokenLifetimeHours).ToUnixTimeSeconds()}"; + var encrypted = SymmetricEncryption.Encrypt(payload, SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); + + return Convert.ToBase64String(Encoding.UTF8.GetBytes(encrypted)); + } + public static string ExtratHref(string url) { int start = url.IndexOf("href=\"") + 6; diff --git a/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs index 6531bfc47..ce2856ad2 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs @@ -41,7 +41,7 @@ public async Task> Process(ReportDeliveryQueueItem item) var request = new RestRequest("User/Reports/InternalRunReport", Method.Get); request.AddParameter("type", item.ScheduledTask.Data); request.AddParameter("departmentId", item.Department.DepartmentId); - request.AddParameter("key", Config.SecurityConfig.InternalReportsToken); + request.AddHeader("X-Internal-Reports-Token", Config.SecurityConfig.InternalReportsToken); var response = await client.ExecuteAsync(request); From 3268460a498ee88169476799af1bd3fffe057369 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 11:12:20 -0700 Subject: [PATCH 4/8] RG-T127 PR#438 fixes --- .../Migrations/M0101_AddUnitTrackingPg.cs | 56 ++++++++++--------- .../User/Controllers/DispatchController.cs | 3 +- Web/Resgrid.Web/Helpers/LinkHelper.cs | 3 +- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs index cd013fce7..ddd067e3d 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs @@ -13,6 +13,36 @@ public class M0101_AddUnitTrackingPg : Migration public override void Up() { + // The units unique constraint is independent of the devices table, so it runs + // unconditionally (each statement is self-guarded). With TransactionBehavior.None a + // re-run after a partial apply (e.g. the duplicate check failed after the devices + // table self-committed) would otherwise skip it because the devices table exists. + // ALTER TABLE ADD CONSTRAINT UNIQUE takes a SHARE ROW EXCLUSIVE lock on the live units + // table and fails if duplicates exist. Pre-validate duplicates, build the index online + // with CONCURRENTLY, then attach it as a constraint (brief lock) so it can still serve + // as the foreign key target below. + Execute.Sql(@" + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM units GROUP BY departmentid, unitid HAVING COUNT(*) > 1) THEN + RAISE EXCEPTION 'uq_units_departmentid_unitid: duplicate (departmentid, unitid) rows exist in units; remove them before rerunning this migration'; + END IF; + END $$;"); + + if (!Schema.Table("units").Index("uq_units_departmentid_unitid").Exists()) + { + Execute.Sql(@" + CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_units_departmentid_unitid + ON units (departmentid, unitid);"); + } + + if (!Schema.Table("units").Constraint("uq_units_departmentid_unitid").Exists()) + { + Execute.Sql(@" + ALTER TABLE units + ADD CONSTRAINT uq_units_departmentid_unitid UNIQUE USING INDEX uq_units_departmentid_unitid;"); + } + if (!Schema.Table(DevicesTable).Exists()) { Create.Table(DevicesTable) @@ -42,32 +72,6 @@ public override void Up() .WithColumn("updatedbyuserid").AsCustom("citext").Nullable() .WithColumn("updatedon").AsDateTime2().Nullable(); - // ALTER TABLE ADD CONSTRAINT UNIQUE takes a SHARE ROW EXCLUSIVE lock on the live units - // table and fails if duplicates exist. Pre-validate duplicates, build the index online - // with CONCURRENTLY, then attach it as a constraint (brief lock) so it can still serve - // as the foreign key target below. - Execute.Sql(@" - DO $$ - BEGIN - IF EXISTS (SELECT 1 FROM units GROUP BY departmentid, unitid HAVING COUNT(*) > 1) THEN - RAISE EXCEPTION 'uq_units_departmentid_unitid: duplicate (departmentid, unitid) rows exist in units; remove them before rerunning this migration'; - END IF; - END $$;"); - - if (!Schema.Table("units").Index("uq_units_departmentid_unitid").Exists()) - { - Execute.Sql(@" - CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_units_departmentid_unitid - ON units (departmentid, unitid);"); - } - - if (!Schema.Table("units").Constraint("uq_units_departmentid_unitid").Exists()) - { - Execute.Sql(@" - ALTER TABLE units - ADD CONSTRAINT uq_units_departmentid_unitid UNIQUE USING INDEX uq_units_departmentid_unitid;"); - } - Create.ForeignKey("fk_unittrackingdevices_units_department_unit") .FromTable(DevicesTable).ForeignColumns("departmentid", "unitid") .ToTable("units").PrimaryColumns("departmentid", "unitid"); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index b44d598a8..4e814169f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Localization; using Newtonsoft.Json; using Resgrid.Framework; @@ -2724,7 +2725,7 @@ private static bool IsValidCallImageToken(int callId, int attachmentId, string t try { - var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(token)).Trim(); + var decoded = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token)).Trim(); var decrypted = SymmetricEncryption.Decrypt(decoded, Config.SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); var parts = decrypted.Split('|'); diff --git a/Web/Resgrid.Web/Helpers/LinkHelper.cs b/Web/Resgrid.Web/Helpers/LinkHelper.cs index 3dfd9f62f..704e2f4b2 100644 --- a/Web/Resgrid.Web/Helpers/LinkHelper.cs +++ b/Web/Resgrid.Web/Helpers/LinkHelper.cs @@ -1,5 +1,6 @@ using System; using System.Text; +using Microsoft.AspNetCore.WebUtilities; using Resgrid.Config; using Resgrid.Framework; @@ -14,7 +15,7 @@ public static string GenerateCallImageToken(int callId, int attachmentId) var payload = $"{callId}|{attachmentId}|{DateTimeOffset.UtcNow.AddHours(CallImageTokenLifetimeHours).ToUnixTimeSeconds()}"; var encrypted = SymmetricEncryption.Encrypt(payload, SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); - return Convert.ToBase64String(Encoding.UTF8.GetBytes(encrypted)); + return WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(encrypted)); } public static string ExtratHref(string url) From eaf3fc38c03b8e390b05abe34d5204637ca94545 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 11:30:03 -0700 Subject: [PATCH 5/8] RG-T127 PR#438 fixes --- .github/workflows/dotnet.yml | 3 ++- .../Migrations/M0101_AddUnitTrackingPg.cs | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 1cbf1d54b..d1df76efb 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -143,7 +143,8 @@ jobs: if (pr) { const body = (pr.body || '') .replace(/##\s*Summary by CodeRabbit[\s\S]*/i, '') - .replace(/Pull Request Description/gi, 'Summary') + .replace(/^(#+\s*)(Pull Request Description|PR Description)\s*$/gim, '$1Summary') + .replace(/Pull Request Description|PR Description/gi, 'Summary') .trim() || 'No release notes provided.'; fs.writeFileSync('release_notes.md', body); } else { diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs index ddd067e3d..efc6c4595 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs @@ -29,6 +29,27 @@ IF EXISTS (SELECT 1 FROM units GROUP BY departmentid, unitid HAVING COUNT(*) > 1 END IF; END $$;"); + // Drop a leftover INVALID index from a previously-failed CREATE INDEX CONCURRENTLY + // build before the existence check below. The check only tests the index name, so an + // invalid index would skip the create and then fail the ADD CONSTRAINT ... USING INDEX + // attach. Only invalid indexes are dropped: a valid one may already back the constraint + // (which cannot be dropped independently) on a re-run after a later step failed. + Execute.Sql(@" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_class c + JOIN pg_index i ON i.indexrelid = c.oid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'uq_units_departmentid_unitid' + AND n.nspname = current_schema() + AND NOT i.indisvalid + ) THEN + DROP INDEX uq_units_departmentid_unitid; + END IF; + END $$;"); + if (!Schema.Table("units").Index("uq_units_departmentid_unitid").Exists()) { Execute.Sql(@" From 55c7fcba47d5c199dc60f4b23686290afe3be211 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 12:05:08 -0700 Subject: [PATCH 6/8] RG-T127 Update to delete department flow --- Core/Resgrid.Model/AuditLogTypes.cs | 4 +- Core/Resgrid.Services/AuditService.cs | 12 +- Core/Resgrid.Services/DeleteService.cs | 153 ++++++++++++++---- Core/Resgrid.Services/QueueService.cs | 15 +- .../User/Controllers/DepartmentController.cs | 12 +- .../Logic/AuditQueueLogic.cs | 20 ++- 6 files changed, 169 insertions(+), 47 deletions(-) diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index d3149b49e..e365d80da 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -171,6 +171,8 @@ public enum AuditLogTypes UnitTrackingDeviceDeleted, UnitTrackingCredentialCreated, UnitTrackingCredentialRotated, - UnitTrackingCredentialRevoked + UnitTrackingCredentialRevoked, + // Department deletion lifecycle + DeleteDepartmentRequestExecuted } } diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs index c5d5d1a5d..f6d9708d6 100644 --- a/Core/Resgrid.Services/AuditService.cs +++ b/Core/Resgrid.Services/AuditService.cs @@ -190,9 +190,15 @@ public string GetAuditLogTypeString(AuditLogTypes logType) return "Unit Tracking Credential Created"; case AuditLogTypes.UnitTrackingCredentialRotated: return "Unit Tracking Credential Rotated"; - case AuditLogTypes.UnitTrackingCredentialRevoked: - return "Unit Tracking Credential Revoked"; - } + case AuditLogTypes.UnitTrackingCredentialRevoked: + return "Unit Tracking Credential Revoked"; + case AuditLogTypes.DeleteDepartmentRequested: + return "Department Deletion Requested"; + case AuditLogTypes.DeleteDepartmentRequestedCancelled: + return "Department Deletion Request Cancelled"; + case AuditLogTypes.DeleteDepartmentRequestExecuted: + return "Department Deletion Executed"; + } return $"Unknown ({logType})"; } diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs index a1346c966..37e748fb1 100644 --- a/Core/Resgrid.Services/DeleteService.cs +++ b/Core/Resgrid.Services/DeleteService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -36,6 +37,7 @@ public class DeleteService : IDeleteService private readonly IQueueService _queueService; private readonly IEmailService _emailService; private readonly IDeleteRepository _deleteRepository; + private readonly IAuditLogsRepository _auditLogsRepository; public DeleteService(IAuthorizationService authorizationService, IDepartmentsService departmentsService, ICallsService callsService, IActionLogsService actionLogsService, IUsersService usersService, @@ -44,7 +46,7 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer IDistributionListsService distributionListsService, IShiftsService shiftsService, IUnitsService unitsService, ICertificationService certificationService, ILogService logService, IInventoryService inventoryService, IEventAggregator eventAggregator, IAddressService addressService, IQueueService queueService, IEmailService emailService, - IDeleteRepository deleteRepository) + IDeleteRepository deleteRepository, IAuditLogsRepository auditLogsRepository) { _authorizationService = authorizationService; _departmentsService = departmentsService; @@ -68,6 +70,7 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer _queueService = queueService; _emailService = emailService; _deleteRepository = deleteRepository; + _auditLogsRepository = auditLogsRepository; } public async Task DeleteUserAsync(int departmentId, string authorizingUserId, string userIdToDelete) @@ -201,32 +204,29 @@ public async Task DeleteUserAsync(int departmentId, string au if (!await _authorizationService.CanUserDeleteDepartmentAsync(authorizingUserId, departmentId)) return DeleteDepartmentResults.UnAuthorized; + // Only one pending deletion request per department; don't stack duplicates (and their emails). + var existingRequest = await _queueService.GetPendingDeleteDepartmentQueueItemAsync(departmentId); + if (existingRequest != null) + return DeleteDepartmentResults.NoFailure; + + var result = await _queueService.EnqueuePendingDeleteDepartmentAsync(departmentId, authorizingUserId, cancellationToken); + var auditEvent = new AuditEvent(); auditEvent.Before = null; auditEvent.DepartmentId = departmentId; auditEvent.UserId = authorizingUserId; auditEvent.Type = AuditLogTypes.DeleteDepartmentRequested; - auditEvent.After = null; + auditEvent.After = result?.CloneJsonToString(); auditEvent.Successful = true; auditEvent.IpAddress = ipAddress; auditEvent.ServerName = Environment.MachineName; auditEvent.UserAgent = userAgent; _eventAggregator.SendMessage(auditEvent); - var result = await _queueService.EnqueuePendingDeleteDepartmentAsync(departmentId, authorizingUserId, cancellationToken); - if (result != null) { var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); - - var ownerUserProfile = await _userProfileService.GetProfileByUserIdAsync(authorizingUserId); - var result2 = await _emailService.SendDeleteDepartmentEmail(ownerUserProfile.User.Email, ownerUserProfile.FullName.AsFirstNameLastName, result); - - foreach (var adminUser in department.AdminUsers) - { - var adminUserProfile = await _userProfileService.GetProfileByUserIdAsync(adminUser); - var result1 = await _emailService.SendDeleteDepartmentEmail(adminUserProfile.User.Email, adminUserProfile.FullName.AsFirstNameLastName, result); - } + await SendDeleteDepartmentEmailToAllAdminsAsync(department, result); } return DeleteDepartmentResults.NoFailure; @@ -237,27 +237,13 @@ public async Task DeleteUserAsync(int departmentId, string au if (!await _authorizationService.CanUserDeleteDepartmentAsync(item.QueuedByUserId, int.Parse(item.SourceId))) return DeleteDepartmentResults.UnAuthorized; - if (item.ToBeCompletedOn.HasValue && DateTime.UtcNow >= item.ToBeCompletedOn.Value.AddDays(-10) && item.ReminderCount == 0) - { - /* - * You have a pending department deletion request, it is within 10 days out and we have no yet sent a reminder. - */ + if (!item.ToBeCompletedOn.HasValue) + return DeleteDepartmentResults.Failure; - var department = await _departmentsService.GetDepartmentByIdAsync(int.Parse(item.SourceId)); + var now = DateTime.UtcNow; + var reminderSent = false; - var ownerUserProfile = await _userProfileService.GetProfileByUserIdAsync(item.QueuedByUserId); - var result2 = await _emailService.SendDeleteDepartmentEmail(ownerUserProfile.User.Email, ownerUserProfile.FullName.AsFirstNameLastName, item); - - foreach (var adminUser in department.AdminUsers) - { - var adminUserProfile = await _userProfileService.GetProfileByUserIdAsync(adminUser); - var result1 = await _emailService.SendDeleteDepartmentEmail(adminUserProfile.User.Email, adminUserProfile.FullName.AsFirstNameLastName, item); - } - - item.ReminderCount += 1; - var result = await _queueService.UpdateQueueItem(item, cancellationToken); - } - else if (item.ToBeCompletedOn.HasValue && DateTime.UtcNow >= item.ToBeCompletedOn.Value) + if (now >= item.ToBeCompletedOn.Value) { /* * You have a pending department deletion request and it can be executed now. @@ -265,9 +251,17 @@ public async Task DeleteUserAsync(int departmentId, string au try { + // Write the execution audit record BEFORE the delete wipes the department's users: + // queued audit events resolve the user profile when processed, which would fail + // after the account is gone, and AuditLogs are not deleted with the department, + // so this row survives as the durable trail for the actual deletion. + await WriteDepartmentDeletionExecutedAuditLogAsync(item, cancellationToken); + Logging.LogInfo($"DeleteService::Executing pending department deletion for DepartmentId {item.SourceId}, requested by UserId {item.QueuedByUserId} on {item.QueuedOn:u}, scheduled for {item.ToBeCompletedOn:u}"); + var result = await _deleteRepository.DeleteDepartmentAndUsersAsync(int.Parse(item.SourceId)); item.CompletedOn = DateTime.UtcNow; + item.Data = "Department deletion executed by the system."; var result2 = await _queueService.UpdateQueueItem(item, cancellationToken); } catch (Exception e) @@ -278,8 +272,103 @@ public async Task DeleteUserAsync(int departmentId, string au return DeleteDepartmentResults.Failure; } } + else if (now.Date >= item.ToBeCompletedOn.Value.Date && item.ReminderCount < 3) + { + /* + * Deletion is scheduled for today (final reminder). + */ + await SendDeleteDepartmentReminderToAllAdminsAsync(item); + item.ReminderCount = 3; + reminderSent = true; + } + else if (now >= item.ToBeCompletedOn.Value.AddDays(-5) && item.ReminderCount < 2) + { + /* + * Deletion is within 5 days and the 5-day reminder has not been sent yet. + */ + await SendDeleteDepartmentReminderToAllAdminsAsync(item); + item.ReminderCount = 2; + reminderSent = true; + } + else if (now >= item.ToBeCompletedOn.Value.AddDays(-14) && item.ReminderCount < 1) + { + /* + * Deletion is within 14 days and the 14-day reminder has not been sent yet. + */ + await SendDeleteDepartmentReminderToAllAdminsAsync(item); + item.ReminderCount = 1; + reminderSent = true; + } + + if (reminderSent) + await _queueService.UpdateQueueItem(item, cancellationToken); return DeleteDepartmentResults.NoFailure; } + + private async Task SendDeleteDepartmentReminderToAllAdminsAsync(QueueItem item) + { + var department = await _departmentsService.GetDepartmentByIdAsync(int.Parse(item.SourceId)); + await SendDeleteDepartmentEmailToAllAdminsAsync(department, item); + } + + private async Task SendDeleteDepartmentEmailToAllAdminsAsync(Department department, QueueItem item) + { + if (department == null || item == null) + return; + + // Notify the managing member AND every department admin, deduped, so no single + // person is the only one who knows a deletion is pending. + var adminUserIds = new List(); + + if (!String.IsNullOrWhiteSpace(department.ManagingUserId)) + adminUserIds.Add(department.ManagingUserId); + + if (department.AdminUsers != null) + adminUserIds.AddRange(department.AdminUsers); + + foreach (var adminUserId in adminUserIds.Distinct()) + { + try + { + var adminUserProfile = await _userProfileService.GetProfileByUserIdAsync(adminUserId); + + if (adminUserProfile?.User == null || String.IsNullOrWhiteSpace(adminUserProfile.User.Email)) + continue; + + await _emailService.SendDeleteDepartmentEmail(adminUserProfile.User.Email, adminUserProfile.FullName.AsFirstNameLastName, item); + } + catch (Exception ex) + { + // A single bad recipient must not block notifications to the remaining admins. + Logging.LogException(ex, $"DeleteService::Failed to send department deletion email to UserId {adminUserId} for DepartmentId {item.SourceId}"); + } + } + } + + private async Task WriteDepartmentDeletionExecutedAuditLogAsync(QueueItem item, CancellationToken cancellationToken) + { + try + { + var auditLog = new AuditLog(); + auditLog.DepartmentId = int.Parse(item.SourceId); + auditLog.UserId = item.QueuedByUserId; + auditLog.LogType = (int)AuditLogTypes.DeleteDepartmentRequestExecuted; + auditLog.Message = $"The system executed the pending department deletion request for department id {item.SourceId}"; + auditLog.Data = $"QueueItemId: {item.QueueItemId}; RequestedByUserId: {item.QueuedByUserId}; QueuedOn (UTC): {item.QueuedOn:u}; ScheduledFor (UTC): {item.ToBeCompletedOn:u}; RemindersSent: {item.ReminderCount}"; + auditLog.Successful = true; + auditLog.IpAddress = null; + auditLog.UserAgent = null; + auditLog.ServerName = Environment.MachineName; + auditLog.LoggedOn = DateTime.UtcNow; + + await _auditLogsRepository.SaveOrUpdateAsync(auditLog, cancellationToken); + } + catch (Exception ex) + { + // Auditing must never block the deletion itself. + Logging.LogException(ex, $"DeleteService::Failed to write department deletion executed audit log for DepartmentId {item.SourceId}"); + } + } } } diff --git a/Core/Resgrid.Services/QueueService.cs b/Core/Resgrid.Services/QueueService.cs index a6bd6d006..71fe0322f 100644 --- a/Core/Resgrid.Services/QueueService.cs +++ b/Core/Resgrid.Services/QueueService.cs @@ -47,8 +47,13 @@ public async Task GetQueueItemByIdAsync(int queueItemId) public async Task GetPendingDeleteDepartmentQueueItemAsync(int departmentId) { var allItems = await _queueItemsRepository.GetAllAsync(); - var depItem = allItems.FirstOrDefault(x => - x.SourceId == departmentId.ToString() && x.ToBeCompletedOn > DateTime.UtcNow && x.QueueType == (int)QueueTypes.DeleteDepartment && x.CompletedOn == null); + + // No ToBeCompletedOn filter: a past-due request that hasn't executed yet is still + // pending and must remain visible (and cancellable) until the worker completes it. + var depItem = allItems.Where(x => + x.SourceId == departmentId.ToString() && x.QueueType == (int)QueueTypes.DeleteDepartment && x.CompletedOn == null) + .OrderByDescending(x => x.QueuedOn) + .FirstOrDefault(); return depItem; } @@ -56,8 +61,12 @@ public async Task GetPendingDeleteDepartmentQueueItemAsync(int depart public async Task> GetAllPendingDeleteDepartmentQueueItemsAsync() { var allItems = await _queueItemsRepository.GetAllAsync(); + + // No ToBeCompletedOn filter: filtering to future-dated items makes past-due requests + // unreachable, so the deletion in HandlePendingDepartmentDeletionRequestAsync would + // never execute. The handler decides between reminders and execution. var depItems = allItems.Where(x => - x.ToBeCompletedOn > DateTime.UtcNow && x.QueueType == (int)QueueTypes.DeleteDepartment && x.CompletedOn == null).ToList(); + x.QueueType == (int)QueueTypes.DeleteDepartment && x.CompletedOn == null).ToList(); return depItems; } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index 6fb0f0fa7..b6f0d0a3e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -2313,21 +2313,21 @@ public async Task CancelDepartmentDeleteRequest(CancellationToken if (queueItem != null) { + var profile = await _userProfileService.GetProfileByUserIdAsync(UserId); + + var cancelledItem = await _queueService.CancelPendingDepartmentDeletionRequest(DepartmentId, profile.FullName.AsFirstNameLastName, cancellationToken); + var auditEvent = new AuditEvent(); - auditEvent.Before = null; + auditEvent.Before = queueItem.CloneJsonToString(); auditEvent.DepartmentId = DepartmentId; auditEvent.UserId = UserId; auditEvent.Type = AuditLogTypes.DeleteDepartmentRequestedCancelled; - auditEvent.After = null; + auditEvent.After = cancelledItem?.CloneJsonToString(); auditEvent.Successful = true; auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); auditEvent.ServerName = Environment.MachineName; auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; _eventAggregator.SendMessage(auditEvent); - - var profile = await _userProfileService.GetProfileByUserIdAsync(UserId); - - await _queueService.CancelPendingDepartmentDeletionRequest(DepartmentId, profile.FullName.AsFirstNameLastName, cancellationToken); } return RedirectToAction("Settings", "Department", new { area = "User" }); diff --git a/Workers/Resgrid.Workers.Framework/Logic/AuditQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/AuditQueueLogic.cs index 417ac8db6..e3be9234c 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/AuditQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/AuditQueueLogic.cs @@ -189,12 +189,28 @@ public class AuditQueueLogic case AuditLogTypes.DeleteDepartmentRequested: auditLog.Message = $"{profile.FullName.AsFirstNameLastName} has requested that the Resgrid department be deleted"; - auditLog.Data = "No Data"; + if (!String.IsNullOrWhiteSpace(auditEvent.After)) + { + var deleteRequestedQueueItem = JsonConvert.DeserializeObject(auditEvent.After); + auditLog.Data = $"QueueItemId: {deleteRequestedQueueItem.QueueItemId}; Scheduled deletion (UTC): {deleteRequestedQueueItem.ToBeCompletedOn:u}; RequestedByUserId: {deleteRequestedQueueItem.QueuedByUserId}"; + } + else + { + auditLog.Data = "No Data"; + } break; case AuditLogTypes.DeleteDepartmentRequestedCancelled: auditLog.Message = $"{profile.FullName.AsFirstNameLastName} canceled the pending department deletion request"; - auditLog.Data = "No Data"; + if (!String.IsNullOrWhiteSpace(auditEvent.Before)) + { + var deleteCancelledQueueItem = JsonConvert.DeserializeObject(auditEvent.Before); + auditLog.Data = $"QueueItemId: {deleteCancelledQueueItem.QueueItemId}; Scheduled deletion (UTC): {deleteCancelledQueueItem.ToBeCompletedOn:u}; OriginallyRequestedByUserId: {deleteCancelledQueueItem.QueuedByUserId}; QueuedOn (UTC): {deleteCancelledQueueItem.QueuedOn:u}"; + } + else + { + auditLog.Data = "No Data"; + } break; case AuditLogTypes.CallReactivated: auditLog.Message = $"{profile.FullName.AsFirstNameLastName} reactivated call"; From 5b0eb686b432c64042a1e3c0a74eae9aa2e7bf57 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 12:31:03 -0700 Subject: [PATCH 7/8] RG-T127 PR#438 fixes --- Core/Resgrid.Services/DeleteService.cs | 76 +++++++++++++------ Core/Resgrid.Services/QueueService.cs | 17 +++-- .../User/Controllers/DepartmentController.cs | 45 +++++++---- .../Logic/AuditQueueLogic.cs | 47 +++++++----- 4 files changed, 120 insertions(+), 65 deletions(-) diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs index 37e748fb1..dc1ef6e96 100644 --- a/Core/Resgrid.Services/DeleteService.cs +++ b/Core/Resgrid.Services/DeleteService.cs @@ -234,14 +234,29 @@ public async Task DeleteUserAsync(int departmentId, string au public async Task HandlePendingDepartmentDeletionRequestAsync(QueueItem item, CancellationToken cancellationToken = default(CancellationToken)) { - if (!await _authorizationService.CanUserDeleteDepartmentAsync(item.QueuedByUserId, int.Parse(item.SourceId))) - return DeleteDepartmentResults.UnAuthorized; + if (!int.TryParse(item.SourceId, out var departmentId)) + { + Logging.LogError($"DeleteService::Pending department deletion QueueItemId {item.QueueItemId} has a malformed SourceId '{item.SourceId}'; setting a terminal state so it is not retried."); + await SetTerminalQueueItemStateAsync(item, $"Department deletion failed: malformed SourceId '{item.SourceId}'.", cancellationToken); + + return DeleteDepartmentResults.Failure; + } if (!item.ToBeCompletedOn.HasValue) return DeleteDepartmentResults.Failure; var now = DateTime.UtcNow; - var reminderSent = false; + + if (!await _authorizationService.CanUserDeleteDepartmentAsync(item.QueuedByUserId, departmentId)) + { + // A past-due item whose requester is no longer the managing user can never execute; + // finalize it so it stops looping in the pending set (a current admin can still + // cancel a not-yet-due item from department settings instead). + if (now >= item.ToBeCompletedOn.Value) + await SetTerminalQueueItemStateAsync(item, $"Department deletion abandoned: requesting user {item.QueuedByUserId} is no longer the managing user.", cancellationToken); + + return DeleteDepartmentResults.UnAuthorized; + } if (now >= item.ToBeCompletedOn.Value) { @@ -255,10 +270,10 @@ public async Task DeleteUserAsync(int departmentId, string au // queued audit events resolve the user profile when processed, which would fail // after the account is gone, and AuditLogs are not deleted with the department, // so this row survives as the durable trail for the actual deletion. - await WriteDepartmentDeletionExecutedAuditLogAsync(item, cancellationToken); + await WriteDepartmentDeletionExecutedAuditLogAsync(item, departmentId, cancellationToken); Logging.LogInfo($"DeleteService::Executing pending department deletion for DepartmentId {item.SourceId}, requested by UserId {item.QueuedByUserId} on {item.QueuedOn:u}, scheduled for {item.ToBeCompletedOn:u}"); - var result = await _deleteRepository.DeleteDepartmentAndUsersAsync(int.Parse(item.SourceId)); + var result = await _deleteRepository.DeleteDepartmentAndUsersAsync(departmentId); item.CompletedOn = DateTime.UtcNow; item.Data = "Department deletion executed by the system."; @@ -267,7 +282,12 @@ public async Task DeleteUserAsync(int departmentId, string au catch (Exception e) { Logging.LogException(e); - Logging.SendExceptionEmail(e, "DeleteDepartment", int.Parse(item.SourceId)); + Logging.SendExceptionEmail(e, "DeleteDepartment", departmentId); + + // Set a terminal state: the pending-queue query no longer filters out past-due + // items, so without this the item is re-queued and re-tried every poll, writing + // a duplicate execution audit row and exception email each time. + await SetTerminalQueueItemStateAsync(item, $"Department deletion failed: {e.Message}", cancellationToken); return DeleteDepartmentResults.Failure; } @@ -277,41 +297,53 @@ public async Task DeleteUserAsync(int departmentId, string au /* * Deletion is scheduled for today (final reminder). */ - await SendDeleteDepartmentReminderToAllAdminsAsync(item); - item.ReminderCount = 3; - reminderSent = true; + await SendDeleteDepartmentReminderAndMarkAsync(item, departmentId, 3, cancellationToken); } else if (now >= item.ToBeCompletedOn.Value.AddDays(-5) && item.ReminderCount < 2) { /* * Deletion is within 5 days and the 5-day reminder has not been sent yet. */ - await SendDeleteDepartmentReminderToAllAdminsAsync(item); - item.ReminderCount = 2; - reminderSent = true; + await SendDeleteDepartmentReminderAndMarkAsync(item, departmentId, 2, cancellationToken); } else if (now >= item.ToBeCompletedOn.Value.AddDays(-14) && item.ReminderCount < 1) { /* * Deletion is within 14 days and the 14-day reminder has not been sent yet. */ - await SendDeleteDepartmentReminderToAllAdminsAsync(item); - item.ReminderCount = 1; - reminderSent = true; + await SendDeleteDepartmentReminderAndMarkAsync(item, departmentId, 1, cancellationToken); } - if (reminderSent) - await _queueService.UpdateQueueItem(item, cancellationToken); - return DeleteDepartmentResults.NoFailure; } - private async Task SendDeleteDepartmentReminderToAllAdminsAsync(QueueItem item) + private async Task SendDeleteDepartmentReminderAndMarkAsync(QueueItem item, int departmentId, int reminderLevel, CancellationToken cancellationToken) { - var department = await _departmentsService.GetDepartmentByIdAsync(int.Parse(item.SourceId)); + await SendDeleteDepartmentReminderToAllAdminsAsync(item, departmentId); + item.ReminderCount = reminderLevel; + await _queueService.UpdateQueueItem(item, cancellationToken); + } + + private async Task SendDeleteDepartmentReminderToAllAdminsAsync(QueueItem item, int departmentId) + { + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); await SendDeleteDepartmentEmailToAllAdminsAsync(department, item); } + private async Task SetTerminalQueueItemStateAsync(QueueItem item, string data, CancellationToken cancellationToken) + { + try + { + item.CompletedOn = DateTime.UtcNow; + item.Data = data; + await _queueService.UpdateQueueItem(item, cancellationToken); + } + catch (Exception ex) + { + Logging.LogException(ex, $"DeleteService::Failed to set terminal state on failed department deletion QueueItemId {item.QueueItemId}"); + } + } + private async Task SendDeleteDepartmentEmailToAllAdminsAsync(Department department, QueueItem item) { if (department == null || item == null) @@ -346,12 +378,12 @@ private async Task SendDeleteDepartmentEmailToAllAdminsAsync(Department departme } } - private async Task WriteDepartmentDeletionExecutedAuditLogAsync(QueueItem item, CancellationToken cancellationToken) + private async Task WriteDepartmentDeletionExecutedAuditLogAsync(QueueItem item, int departmentId, CancellationToken cancellationToken) { try { var auditLog = new AuditLog(); - auditLog.DepartmentId = int.Parse(item.SourceId); + auditLog.DepartmentId = departmentId; auditLog.UserId = item.QueuedByUserId; auditLog.LogType = (int)AuditLogTypes.DeleteDepartmentRequestExecuted; auditLog.Message = $"The system executed the pending department deletion request for department id {item.SourceId}"; diff --git a/Core/Resgrid.Services/QueueService.cs b/Core/Resgrid.Services/QueueService.cs index 71fe0322f..b90457a2b 100644 --- a/Core/Resgrid.Services/QueueService.cs +++ b/Core/Resgrid.Services/QueueService.cs @@ -13,6 +13,13 @@ namespace Resgrid.Services { public class QueueService : IQueueService { + // Shared predicate for pending department-deletion queue items. No ToBeCompletedOn filter: + // a past-due request that hasn't executed yet is still pending and must remain visible + // (and cancellable) until the worker completes it; the handler decides between reminders + // and execution. Keep both queries below on this single predicate so they can't drift. + private static readonly Func IsPendingDepartmentDeletion = + x => x.QueueType == (int)QueueTypes.DeleteDepartment && x.CompletedOn == null; + private readonly IQueueItemsRepository _queueItemsRepository; private readonly IOutboundQueueProvider _outboundQueueProvider; private readonly IDepartmentSettingsService _departmentSettingsService; @@ -48,10 +55,8 @@ public async Task GetPendingDeleteDepartmentQueueItemAsync(int depart { var allItems = await _queueItemsRepository.GetAllAsync(); - // No ToBeCompletedOn filter: a past-due request that hasn't executed yet is still - // pending and must remain visible (and cancellable) until the worker completes it. var depItem = allItems.Where(x => - x.SourceId == departmentId.ToString() && x.QueueType == (int)QueueTypes.DeleteDepartment && x.CompletedOn == null) + x.SourceId == departmentId.ToString() && IsPendingDepartmentDeletion(x)) .OrderByDescending(x => x.QueuedOn) .FirstOrDefault(); @@ -62,11 +67,7 @@ public async Task> GetAllPendingDeleteDepartmentQueueItemsAsync( { var allItems = await _queueItemsRepository.GetAllAsync(); - // No ToBeCompletedOn filter: filtering to future-dated items makes past-due requests - // unreachable, so the deletion in HandlePendingDepartmentDeletionRequestAsync would - // never execute. The handler decides between reminders and execution. - var depItems = allItems.Where(x => - x.QueueType == (int)QueueTypes.DeleteDepartment && x.CompletedOn == null).ToList(); + var depItems = allItems.Where(IsPendingDepartmentDeletion).ToList(); return depItems; } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index b6f0d0a3e..772979c56 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -2313,21 +2313,36 @@ public async Task CancelDepartmentDeleteRequest(CancellationToken if (queueItem != null) { - var profile = await _userProfileService.GetProfileByUserIdAsync(UserId); - - var cancelledItem = await _queueService.CancelPendingDepartmentDeletionRequest(DepartmentId, profile.FullName.AsFirstNameLastName, cancellationToken); - - var auditEvent = new AuditEvent(); - auditEvent.Before = queueItem.CloneJsonToString(); - auditEvent.DepartmentId = DepartmentId; - auditEvent.UserId = UserId; - auditEvent.Type = AuditLogTypes.DeleteDepartmentRequestedCancelled; - auditEvent.After = cancelledItem?.CloneJsonToString(); - auditEvent.Successful = true; - auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); - auditEvent.ServerName = Environment.MachineName; - auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; - _eventAggregator.SendMessage(auditEvent); + try + { + var profile = await _userProfileService.GetProfileByUserIdAsync(UserId); + + if (profile == null) + return NotFound(); + + var cancelledItem = await _queueService.CancelPendingDepartmentDeletionRequest(DepartmentId, profile.FullName.AsFirstNameLastName, cancellationToken); + + // Only audit when the cancellation actually persisted; a null result means no + // pending request was updated, so a success audit would be a false trail. + if (cancelledItem != null) + { + var auditEvent = new AuditEvent(); + auditEvent.Before = queueItem.CloneJsonToString(); + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = AuditLogTypes.DeleteDepartmentRequestedCancelled; + auditEvent.After = cancelledItem.CloneJsonToString(); + auditEvent.Successful = true; + auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); + auditEvent.ServerName = Environment.MachineName; + auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + _eventAggregator.SendMessage(auditEvent); + } + } + catch (Exception ex) + { + Logging.LogException(ex, $"DepartmentController::CancelDepartmentDeleteRequest failed for DepartmentId {DepartmentId} by UserId {UserId}"); + } } return RedirectToAction("Settings", "Department", new { area = "User" }); diff --git a/Workers/Resgrid.Workers.Framework/Logic/AuditQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/AuditQueueLogic.cs index e3be9234c..bcbba84c7 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/AuditQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/AuditQueueLogic.cs @@ -188,29 +188,11 @@ public class AuditQueueLogic break; case AuditLogTypes.DeleteDepartmentRequested: auditLog.Message = $"{profile.FullName.AsFirstNameLastName} has requested that the Resgrid department be deleted"; - - if (!String.IsNullOrWhiteSpace(auditEvent.After)) - { - var deleteRequestedQueueItem = JsonConvert.DeserializeObject(auditEvent.After); - auditLog.Data = $"QueueItemId: {deleteRequestedQueueItem.QueueItemId}; Scheduled deletion (UTC): {deleteRequestedQueueItem.ToBeCompletedOn:u}; RequestedByUserId: {deleteRequestedQueueItem.QueuedByUserId}"; - } - else - { - auditLog.Data = "No Data"; - } + auditLog.Data = GetDepartmentDeletionQueueItemAuditData(auditEvent.After, false); break; case AuditLogTypes.DeleteDepartmentRequestedCancelled: auditLog.Message = $"{profile.FullName.AsFirstNameLastName} canceled the pending department deletion request"; - - if (!String.IsNullOrWhiteSpace(auditEvent.Before)) - { - var deleteCancelledQueueItem = JsonConvert.DeserializeObject(auditEvent.Before); - auditLog.Data = $"QueueItemId: {deleteCancelledQueueItem.QueueItemId}; Scheduled deletion (UTC): {deleteCancelledQueueItem.ToBeCompletedOn:u}; OriginallyRequestedByUserId: {deleteCancelledQueueItem.QueuedByUserId}; QueuedOn (UTC): {deleteCancelledQueueItem.QueuedOn:u}"; - } - else - { - auditLog.Data = "No Data"; - } + auditLog.Data = GetDepartmentDeletionQueueItemAuditData(auditEvent.Before, true); break; case AuditLogTypes.CallReactivated: auditLog.Message = $"{profile.FullName.AsFirstNameLastName} reactivated call"; @@ -821,5 +803,30 @@ public class AuditQueueLogic return success; } + + private static string GetDepartmentDeletionQueueItemAuditData(string queueItemJson, bool cancelled) + { + if (String.IsNullOrWhiteSpace(queueItemJson)) + return "No Data"; + + try + { + var queueItem = JsonConvert.DeserializeObject(queueItemJson); + + if (queueItem == null) + return "No Data"; + + if (cancelled) + return $"QueueItemId: {queueItem.QueueItemId}; Scheduled deletion (UTC): {queueItem.ToBeCompletedOn:u}; OriginallyRequestedByUserId: {queueItem.QueuedByUserId}; QueuedOn (UTC): {queueItem.QueuedOn:u}"; + + return $"QueueItemId: {queueItem.QueueItemId}; Scheduled deletion (UTC): {queueItem.ToBeCompletedOn:u}; RequestedByUserId: {queueItem.QueuedByUserId}"; + } + catch (Exception ex) + { + // A null/malformed payload must not drop the whole audit row via the outer catch. + Logging.LogException(ex, "AuditQueueLogic::Failed to deserialize department deletion queue item audit payload"); + return "No Data"; + } + } } } From 17d0f5c4a5189ef803272032fc45a1a64dea55c4 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 12:44:41 -0700 Subject: [PATCH 8/8] RG-T127 PR#438 Fixes --- Core/Resgrid.Model/QueueItem.cs | 3 ++ Core/Resgrid.Services/DeleteService.cs | 43 +++++++++++++++---- .../M0102_AddAttemptCountToQueueItems.cs | 20 +++++++++ .../M0102_AddAttemptCountToQueueItemsPg.cs | 20 +++++++++ 4 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0102_AddAttemptCountToQueueItems.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0102_AddAttemptCountToQueueItemsPg.cs diff --git a/Core/Resgrid.Model/QueueItem.cs b/Core/Resgrid.Model/QueueItem.cs index 7038c2d41..421fc8e77 100644 --- a/Core/Resgrid.Model/QueueItem.cs +++ b/Core/Resgrid.Model/QueueItem.cs @@ -50,6 +50,9 @@ public class QueueItem : IEntity [ProtoMember(12)] public int ReminderCount { get; set; } + [ProtoMember(13)] + public int AttemptCount { get; set; } + [NotMapped] public int DequeueCount { get; set; } diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs index dc1ef6e96..6393dd053 100644 --- a/Core/Resgrid.Services/DeleteService.cs +++ b/Core/Resgrid.Services/DeleteService.cs @@ -266,15 +266,17 @@ public async Task DeleteUserAsync(int departmentId, string au try { - // Write the execution audit record BEFORE the delete wipes the department's users: - // queued audit events resolve the user profile when processed, which would fail - // after the account is gone, and AuditLogs are not deleted with the department, - // so this row survives as the durable trail for the actual deletion. - await WriteDepartmentDeletionExecutedAuditLogAsync(item, departmentId, cancellationToken); Logging.LogInfo($"DeleteService::Executing pending department deletion for DepartmentId {item.SourceId}, requested by UserId {item.QueuedByUserId} on {item.QueuedOn:u}, scheduled for {item.ToBeCompletedOn:u}"); var result = await _deleteRepository.DeleteDepartmentAndUsersAsync(departmentId); + // Write the execution audit record only after the delete succeeds: retried + // attempts must not each leave an "executed" row. The row is written directly + // (not via the queued audit events, which resolve the now-deleted user profile + // when processed), and AuditLogs are not deleted with the department, so it + // survives as the durable trail for the actual deletion. + await WriteDepartmentDeletionExecutedAuditLogAsync(item, departmentId, cancellationToken); + item.CompletedOn = DateTime.UtcNow; item.Data = "Department deletion executed by the system."; var result2 = await _queueService.UpdateQueueItem(item, cancellationToken); @@ -284,10 +286,33 @@ public async Task DeleteUserAsync(int departmentId, string au Logging.LogException(e); Logging.SendExceptionEmail(e, "DeleteDepartment", departmentId); - // Set a terminal state: the pending-queue query no longer filters out past-due - // items, so without this the item is re-queued and re-tried every poll, writing - // a duplicate execution audit row and exception email each time. - await SetTerminalQueueItemStateAsync(item, $"Department deletion failed: {e.Message}", cancellationToken); + // Bounded retry: a transient failure (DB timeout/deadlock) must not permanently + // abort a scheduled deletion. The item stays pending (CompletedOn null) and is + // retried on the next poll until the attempt budget is exhausted; only then set + // a terminal state so it stops re-queuing. + const int maxAttempts = 5; + item.AttemptCount += 1; + + if (item.AttemptCount >= maxAttempts) + { + await SetTerminalQueueItemStateAsync(item, $"Department deletion permanently failed after {item.AttemptCount} attempts: {e.Message}", cancellationToken); + } + else + { + item.Data = $"Department deletion attempt {item.AttemptCount} failed: {e.Message}"; + + try + { + await _queueService.UpdateQueueItem(item, cancellationToken); + } + catch (Exception updateEx) + { + // Persisting the retry state failed; the item stays pending and is retried + // next poll regardless, but don't let this mask the original exception or + // abort the worker's processing of other pending items. + Logging.LogException(updateEx, $"DeleteService::Failed to persist retry state for department deletion QueueItemId {item.QueueItemId}"); + } + } return DeleteDepartmentResults.Failure; } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0102_AddAttemptCountToQueueItems.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0102_AddAttemptCountToQueueItems.cs new file mode 100644 index 000000000..a14edb011 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0102_AddAttemptCountToQueueItems.cs @@ -0,0 +1,20 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(102)] + public class M0102_AddAttemptCountToQueueItems : Migration + { + public override void Up() + { + // Bounded-retry counter for system queue items (e.g. department deletion) so a + // transient execution failure can be retried without looping forever. + Alter.Table("QueueItems").AddColumn("AttemptCount").AsInt32().NotNullable().WithDefaultValue(0); + } + + public override void Down() + { + + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0102_AddAttemptCountToQueueItemsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0102_AddAttemptCountToQueueItemsPg.cs new file mode 100644 index 000000000..967e561fb --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0102_AddAttemptCountToQueueItemsPg.cs @@ -0,0 +1,20 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(102)] + public class M0102_AddAttemptCountToQueueItemsPg : Migration + { + public override void Up() + { + // Bounded-retry counter for system queue items (e.g. department deletion) so a + // transient execution failure can be retried without looping forever. + Alter.Table("QueueItems".ToLower()).AddColumn("AttemptCount".ToLower()).AsInt32().NotNullable().WithDefaultValue(0); + } + + public override void Down() + { + + } + } +}