From c5daf56b486f99f7824853cd655591366c18f1bc Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 26 Aug 2026 18:35:48 -0400 Subject: [PATCH 1/7] fix(api): honor a partially supplied event date range GetDateRange discarded both bounds whenever either was missing, so ?start= or ?end= alone silently returned the default last 30 days across all eleven callers. Resolve each bound independently instead: an absent start anchors 30 days before the supplied end, an absent end runs to the end of the current day. The inverted-range swap and the 367-day cap now apply to every case rather than only to fully supplied ranges. Standardizes the cap message on the more descriptive of the two variants that existed, which is the one the Public API already returned. [PM-38743] --- src/Api/Utilities/ApiHelpers.cs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Api/Utilities/ApiHelpers.cs b/src/Api/Utilities/ApiHelpers.cs index 3c0701b1bd47..f90bc724402d 100644 --- a/src/Api/Utilities/ApiHelpers.cs +++ b/src/Api/Utilities/ApiHelpers.cs @@ -80,26 +80,25 @@ public async static Task HandleAzureEvents(HttpRequest request, /// start date and time /// end date and time /// - /// If start or end are null, will return a range of the last 30 days. - /// If a time span greater than 367 days is passed will throw BadRequestException. + /// A supplied bound is always honored; the missing bound is inferred from it. + /// With neither supplied, returns the last 30 days. + /// With only , the range runs to the end of the current day. + /// With only , the range covers the 30 days before it. + /// An inverted range is swapped. A range greater than 367 days throws BadRequestException. /// public static Tuple GetDateRange(DateTime? start, DateTime? end) { - if (!end.HasValue || !start.HasValue) - { - end = DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1); - start = DateTime.UtcNow.Date.AddDays(-30); - } - else if (start.Value > end.Value) + start ??= end?.AddDays(-30) ?? DateTime.UtcNow.Date.AddDays(-30); + end ??= DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1); + + if (start.Value > end.Value) { - var newEnd = start; - start = end; - end = newEnd; + (start, end) = (end, start); } if ((end.Value - start.Value) > TimeSpan.FromDays(367)) { - throw new BadRequestException("Range too large."); + throw new BadRequestException("Date range must be < 367 days."); } return new Tuple(start.Value, end.Value); From 966bcea0934ac8fa20d708ce83454aaea56b130c Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 26 Aug 2026 18:36:27 -0400 Subject: [PATCH 2/7] refactor(api): collapse the duplicated event date-range logic EventFilterRequestModel.ToDateRange was a copy of ApiHelpers.GetDateRange that had already diverged on its exception message. Delegate to the shared helper so the Public API picks up the partial-range fix and there is one implementation to maintain. ToDateRange still writes the resolved bounds back onto the model: EventDiagnosticLogger reads Start and End after this call to log the query's effective filters. [PM-38743] --- .../Public/Models/EventFilterRequestModel.cs | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs b/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs index 20984c2cb078..041edaf4cdc4 100644 --- a/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs +++ b/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs @@ -1,18 +1,18 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.Exceptions; +using Bit.Api.Utilities; namespace Bit.Api.Dirt.Public.Models; public class EventFilterRequestModel { /// - /// The start date. Must be less than the end date. + /// The start date. If omitted, defaults to 30 days before the end date. /// public DateTime? Start { get; set; } /// - /// The end date. Must be greater than the start date. + /// The end date. If omitted, defaults to the end of the current day. /// public DateTime? End { get; set; } /// @@ -38,23 +38,9 @@ public class EventFilterRequestModel public Tuple ToDateRange() { - if (!End.HasValue || !Start.HasValue) - { - End = DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1); - Start = DateTime.UtcNow.Date.AddDays(-30); - } - else if (Start.Value > End.Value) - { - var newEnd = Start; - Start = End; - End = newEnd; - } - - if ((End.Value - Start.Value) > TimeSpan.FromDays(367)) - { - throw new BadRequestException("Date range must be < 367 days."); - } - - return new Tuple(Start.Value, End.Value); + var dateRange = ApiHelpers.GetDateRange(Start, End); + Start = dateRange.Item1; + End = dateRange.Item2; + return dateRange; } } From 6de288076965f82aeda27457786bcfe80be1560a Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 26 Aug 2026 18:36:57 -0400 Subject: [PATCH 3/7] docs(api): document partial date filtering on GET /public/events The endpoint's Swagger remarks described only the no-filter default and implied that a partial range fell back to it. Describe what each single bound now resolves to, and state the 367-day cap that was already enforced but never documented. [PM-38743] --- src/Api/Dirt/Public/Controllers/EventsController.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Api/Dirt/Public/Controllers/EventsController.cs b/src/Api/Dirt/Public/Controllers/EventsController.cs index b7ace39503ea..83fbc3488c2a 100644 --- a/src/Api/Dirt/Public/Controllers/EventsController.cs +++ b/src/Api/Dirt/Public/Controllers/EventsController.cs @@ -51,7 +51,9 @@ public EventsController( /// /// /// Returns a filtered list of your organization's event logs, paged by a continuation token. - /// If no filters are provided, it will return the last 30 days of event for the organization. + /// If no date filters are provided, it will return the last 30 days of events for the organization. + /// Providing only start returns events from then through the end of the current day; + /// providing only end returns the 30 days before it. A range greater than 367 days is rejected. /// [HttpGet] [ProducesResponseType(typeof(PagedListResponseModel), (int)HttpStatusCode.OK)] From 56dd3b7ab70b00aef914b1b0a7d4b1883da41770 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 26 Aug 2026 18:37:26 -0400 Subject: [PATCH 4/7] test(api): cover event date-range resolution GetDateRange served eleven endpoints with no unit coverage, which is how the partial-range bug shipped. Cover every branch: no bounds, start only, end only, both, inverted, over the 367-day cap, and start-only past the cap. Adds EventFilterRequestModelTests to pin the write-back that EventDiagnosticLogger depends on. [PM-38743] --- .../Models/EventFilterRequestModelTests.cs | 29 +++++++ test/Api.Test/Utilities/ApiHelpersTests.cs | 87 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 test/Api.Test/Dirt/Public/Models/EventFilterRequestModelTests.cs diff --git a/test/Api.Test/Dirt/Public/Models/EventFilterRequestModelTests.cs b/test/Api.Test/Dirt/Public/Models/EventFilterRequestModelTests.cs new file mode 100644 index 000000000000..eb5be70a101c --- /dev/null +++ b/test/Api.Test/Dirt/Public/Models/EventFilterRequestModelTests.cs @@ -0,0 +1,29 @@ +using Bit.Api.Dirt.Public.Models; +using Xunit; + +namespace Bit.Api.Test.Dirt.Public.Models; + +public class EventFilterRequestModelTests +{ + [Fact] + public void ToDateRange_OnlyStartSupplied_DoesNotFallBackToThirtyDayDefault() + { + var suppliedStart = DateTime.UtcNow.AddDays(-3); + var request = new EventFilterRequestModel { Start = suppliedStart }; + + var dateRange = request.ToDateRange(); + + Assert.Equal(suppliedStart, dateRange.Item1); + } + + [Fact] + public void ToDateRange_WritesResolvedBoundsBackOntoTheModelForDiagnosticLogging() + { + var request = new EventFilterRequestModel(); + + var dateRange = request.ToDateRange(); + + Assert.Equal(dateRange.Item1, request.Start); + Assert.Equal(dateRange.Item2, request.End); + } +} diff --git a/test/Api.Test/Utilities/ApiHelpersTests.cs b/test/Api.Test/Utilities/ApiHelpersTests.cs index ec8f10ca6b73..358867458d78 100644 --- a/test/Api.Test/Utilities/ApiHelpersTests.cs +++ b/test/Api.Test/Utilities/ApiHelpersTests.cs @@ -1,6 +1,7 @@ using System.Text; using Bit.Api.Utilities; using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Exceptions; using Microsoft.AspNetCore.Http; using NSubstitute; using Xunit; @@ -22,5 +23,91 @@ public async Task ReadJsonFileFromBody_Success() Assert.Equal(8, license.Version); } + [Fact] + public void GetDateRange_NeitherBoundSupplied_ReturnsLastThirtyDays() + { + var (start, end) = ApiHelpers.GetDateRange(null, null); + + Assert.Equal(DateTime.UtcNow.Date.AddDays(-30), start); + Assert.Equal(DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1), end); + } + + [Fact] + public void GetDateRange_OnlyStartSupplied_KeepsStartAndRunsToEndOfToday() + { + var suppliedStart = DateTime.UtcNow.AddDays(-3); + + var (start, end) = ApiHelpers.GetDateRange(suppliedStart, null); + + Assert.Equal(suppliedStart, start); + Assert.Equal(DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1), end); + } + + [Fact] + public void GetDateRange_OnlyEndSupplied_KeepsEndAndStartsThirtyDaysBefore() + { + var suppliedEnd = new DateTime(2026, 6, 8, 14, 9, 35, DateTimeKind.Utc); + + var (start, end) = ApiHelpers.GetDateRange(null, suppliedEnd); + + Assert.Equal(suppliedEnd.AddDays(-30), start); + Assert.Equal(suppliedEnd, end); + } + + [Fact] + public void GetDateRange_BothBoundsSupplied_ReturnsThemUnchanged() + { + var suppliedStart = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + var suppliedEnd = new DateTime(2026, 6, 8, 0, 0, 0, DateTimeKind.Utc); + + var (start, end) = ApiHelpers.GetDateRange(suppliedStart, suppliedEnd); + + Assert.Equal(suppliedStart, start); + Assert.Equal(suppliedEnd, end); + } + + [Fact] + public void GetDateRange_InvertedBounds_SwapsThem() + { + var earlier = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + var later = new DateTime(2026, 6, 8, 0, 0, 0, DateTimeKind.Utc); + + var (start, end) = ApiHelpers.GetDateRange(later, earlier); + + Assert.Equal(earlier, start); + Assert.Equal(later, end); + } + + [Fact] + public void GetDateRange_RangeExceedsCap_ThrowsBadRequest() + { + var suppliedStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var suppliedEnd = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var exception = Assert.Throws( + () => ApiHelpers.GetDateRange(suppliedStart, suppliedEnd)); + + Assert.Equal("Date range must be < 367 days.", exception.Message); + } + + [Fact] + public void GetDateRange_OnlyStartSuppliedBeyondCap_ThrowsBadRequest() + { + var suppliedStart = DateTime.UtcNow.AddDays(-400); + + Assert.Throws(() => ApiHelpers.GetDateRange(suppliedStart, null)); + } + + [Fact] + public void GetDateRange_OnlyStartSuppliedInTheFuture_SwapsRatherThanInverting() + { + var suppliedStart = DateTime.UtcNow.AddDays(3); + + var (start, end) = ApiHelpers.GetDateRange(suppliedStart, null); + + Assert.True(start <= end); + Assert.Equal(suppliedStart, end); + } + const string testFile = "{\"licenseKey\": \"licenseKey\", \"installationId\": \"6285f891-b2ec-4047-84c5-2eb7f7747e74\", \"id\": \"1065216d-5854-4326-838d-635487f30b43\",\"name\": \"Test Org\",\"billingEmail\": \"test@email.com\",\"businessName\": null,\"enabled\": true, \"plan\": \"Enterprise (Annually)\",\"planType\": 11,\"seats\": 6,\"maxCollections\": null,\"usePolicies\": true,\"useSso\": true,\"useKeyConnector\": false,\"useGroups\": true,\"useEvents\": true,\"useDirectory\": true,\"useTotp\": true,\"use2fa\": true,\"useApi\": true,\"useResetPassword\": true,\"maxStorageGb\": 1,\"selfHost\": true,\"usersGetPremium\": true,\"version\": 8,\"issued\": \"2022-01-25T21:58:38.9454581Z\",\"refresh\": \"2022-01-28T14:26:31Z\",\"expires\": \"2022-01-28T14:26:31Z\",\"trial\": true,\"hash\": \"testvalue\",\"signature\": \"signature\"}"; } From d3747cd53caef14d11764c853514a2b39fb679b8 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 3 Sep 2026 13:44:19 -0400 Subject: [PATCH 5/7] fix(api): clamp the inferred start bound at DateTime.MinValue Inferring the start bound as end.AddDays(-30) threw ArgumentOutOfRangeException for an end bound within 30 days of DateTime.MinValue. ExceptionHandlerFilterAttribute has no branch for that type, so it fell through to the catch-all and returned 500 with an error-level log entry. Before this branch existed the end-only case discarded the supplied value without doing arithmetic on it, so the same request returned 200, which made this a new failure mode on all twelve callers. Clamp the inferred bound instead. GET /public/events?end=0001-01-10 now resolves to MinValue through the supplied end and returns 200 with an empty result set. [PM-38743] --- src/Api/Utilities/ApiHelpers.cs | 9 +++++++- test/Api.Test/Utilities/ApiHelpersTests.cs | 27 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/Api/Utilities/ApiHelpers.cs b/src/Api/Utilities/ApiHelpers.cs index f90bc724402d..4576f80684df 100644 --- a/src/Api/Utilities/ApiHelpers.cs +++ b/src/Api/Utilities/ApiHelpers.cs @@ -88,7 +88,11 @@ public async static Task HandleAzureEvents(HttpRequest request, /// public static Tuple GetDateRange(DateTime? start, DateTime? end) { - start ??= end?.AddDays(-30) ?? DateTime.UtcNow.Date.AddDays(-30); + if (!start.HasValue) + { + start = end.HasValue ? ThirtyDaysBefore(end.Value) : DateTime.UtcNow.Date.AddDays(-30); + } + end ??= DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1); if (start.Value > end.Value) @@ -103,4 +107,7 @@ public static Tuple GetDateRange(DateTime? start, DateTime? return new Tuple(start.Value, end.Value); } + + private static DateTime ThirtyDaysBefore(DateTime value) => + value - DateTime.MinValue < TimeSpan.FromDays(30) ? DateTime.MinValue : value.AddDays(-30); } diff --git a/test/Api.Test/Utilities/ApiHelpersTests.cs b/test/Api.Test/Utilities/ApiHelpersTests.cs index 358867458d78..2a6f72b69d36 100644 --- a/test/Api.Test/Utilities/ApiHelpersTests.cs +++ b/test/Api.Test/Utilities/ApiHelpersTests.cs @@ -54,6 +54,33 @@ public void GetDateRange_OnlyEndSupplied_KeepsEndAndStartsThirtyDaysBefore() Assert.Equal(suppliedEnd, end); } + [Fact] + public void GetDateRange_OnlyEndSuppliedNearMinValue_ClampsStartInsteadOfThrowing() + { + var suppliedEnd = new DateTime(1, 1, 10, 0, 0, 0, DateTimeKind.Utc); + + var (start, end) = ApiHelpers.GetDateRange(null, suppliedEnd); + + Assert.Equal(DateTime.MinValue, start); + Assert.Equal(suppliedEnd, end); + } + + [Fact] + public void GetDateRange_OnlyEndSuppliedExactlyThirtyDaysAfterMinValue_ClampsToMinValue() + { + var suppliedEnd = DateTime.MinValue.AddDays(30); + + var (start, _) = ApiHelpers.GetDateRange(null, suppliedEnd); + + Assert.Equal(DateTime.MinValue, start); + } + + [Fact] + public void GetDateRange_OnlyStartSuppliedAtMaxValue_ThrowsBadRequestRatherThanOverflowing() + { + Assert.Throws(() => ApiHelpers.GetDateRange(DateTime.MaxValue, null)); + } + [Fact] public void GetDateRange_BothBoundsSupplied_ReturnsThemUnchanged() { From dacecc7ad6a5ec18c5d5c6ec16d90c449e652db6 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 9 Sep 2026 10:21:27 -0600 Subject: [PATCH 6/7] fix(api): resolve a missing end bound to now, not end of day An absent end bound resolved to the last millisecond of the current day, so every unfiltered or start-only event query reached into the future. Event dates are stamped server side from DateTime.UtcNow, so nothing legitimately lands there and the extra window only absorbs clock skew between app servers. Resolve to DateTime.UtcNow instead. This also tightens the no-filter default, which has returned through end of day since before this branch existed. Addresses review feedback: - default the missing end bound to UtcNow [PM-38743] --- src/Api/Dirt/Public/Controllers/EventsController.cs | 2 +- src/Api/Utilities/ApiHelpers.cs | 4 ++-- test/Api.Test/Utilities/ApiHelpersTests.cs | 9 ++++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Api/Dirt/Public/Controllers/EventsController.cs b/src/Api/Dirt/Public/Controllers/EventsController.cs index 83fbc3488c2a..f699b9631d2c 100644 --- a/src/Api/Dirt/Public/Controllers/EventsController.cs +++ b/src/Api/Dirt/Public/Controllers/EventsController.cs @@ -52,7 +52,7 @@ public EventsController( /// /// Returns a filtered list of your organization's event logs, paged by a continuation token. /// If no date filters are provided, it will return the last 30 days of events for the organization. - /// Providing only start returns events from then through the end of the current day; + /// Providing only start returns events from then through the current time; /// providing only end returns the 30 days before it. A range greater than 367 days is rejected. /// [HttpGet] diff --git a/src/Api/Utilities/ApiHelpers.cs b/src/Api/Utilities/ApiHelpers.cs index 4576f80684df..9d166fcf92bd 100644 --- a/src/Api/Utilities/ApiHelpers.cs +++ b/src/Api/Utilities/ApiHelpers.cs @@ -82,7 +82,7 @@ public async static Task HandleAzureEvents(HttpRequest request, /// /// A supplied bound is always honored; the missing bound is inferred from it. /// With neither supplied, returns the last 30 days. - /// With only , the range runs to the end of the current day. + /// With only , the range runs to the current time. /// With only , the range covers the 30 days before it. /// An inverted range is swapped. A range greater than 367 days throws BadRequestException. /// @@ -93,7 +93,7 @@ public static Tuple GetDateRange(DateTime? start, DateTime? start = end.HasValue ? ThirtyDaysBefore(end.Value) : DateTime.UtcNow.Date.AddDays(-30); } - end ??= DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1); + end ??= DateTime.UtcNow; if (start.Value > end.Value) { diff --git a/test/Api.Test/Utilities/ApiHelpersTests.cs b/test/Api.Test/Utilities/ApiHelpersTests.cs index 2a6f72b69d36..1eea4acb0801 100644 --- a/test/Api.Test/Utilities/ApiHelpersTests.cs +++ b/test/Api.Test/Utilities/ApiHelpersTests.cs @@ -26,21 +26,24 @@ public async Task ReadJsonFileFromBody_Success() [Fact] public void GetDateRange_NeitherBoundSupplied_ReturnsLastThirtyDays() { + var before = DateTime.UtcNow; + var (start, end) = ApiHelpers.GetDateRange(null, null); Assert.Equal(DateTime.UtcNow.Date.AddDays(-30), start); - Assert.Equal(DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1), end); + Assert.InRange(end, before, DateTime.UtcNow); } [Fact] - public void GetDateRange_OnlyStartSupplied_KeepsStartAndRunsToEndOfToday() + public void GetDateRange_OnlyStartSupplied_KeepsStartAndRunsToNow() { var suppliedStart = DateTime.UtcNow.AddDays(-3); + var before = DateTime.UtcNow; var (start, end) = ApiHelpers.GetDateRange(suppliedStart, null); Assert.Equal(suppliedStart, start); - Assert.Equal(DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1), end); + Assert.InRange(end, before, DateTime.UtcNow); } [Fact] From bfaaa053198f8c0ee1a9300b627a1e8f0dad6611 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 9 Sep 2026 15:05:33 -0600 Subject: [PATCH 7/7] docs(api): correct the event date property docs for the UtcNow default The previous commit resolved a missing end bound to DateTime.UtcNow and updated the remarks on GetDateRange and the events controller, but left the EventFilterRequestModel property docs describing the end-of-day behavior they replaced. Api.csproj emits a DocumentationFile and AddSwaggerGen includes every emitted XML, so this model's summaries are published as the start and end query parameter descriptions on GET /public/events. The spec therefore contradicted itself. Also tightens the start summary, which described only the case where an end bound is supplied. With neither supplied the start resolves to 30 days before today's date rather than 30 days before the resolved end. [PM-38743] --- src/Api/Dirt/Public/Models/EventFilterRequestModel.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs b/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs index 041edaf4cdc4..4e24545e5945 100644 --- a/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs +++ b/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs @@ -8,11 +8,11 @@ namespace Bit.Api.Dirt.Public.Models; public class EventFilterRequestModel { /// - /// The start date. If omitted, defaults to 30 days before the end date. + /// The start date. If omitted, defaults to 30 days before the end date (or 30 days ago when no end date is given). /// public DateTime? Start { get; set; } /// - /// The end date. If omitted, defaults to the end of the current day. + /// The end date. If omitted, defaults to the current time. /// public DateTime? End { get; set; } ///