[PM-34222] Stamp collected events with server time - #8296
Conversation
CollectController forwarded the client-supplied Date straight to EventService, so client-originated events (copy password, view item, autofill, export) were recorded on the user's device clock while server-originated events used the server clock. A device running a few minutes slow produced audit logs where an item's password was copied before the item was created. Passing no date lets EventService's existing DateTime.UtcNow fallback apply per event, putting every event in the log on one clock. This also fixes payloads that omit `date` entirely, which previously bound to DateTime.MinValue and were stored as year-1 events. The Date property stays on EventModel so existing clients keep working; it is now ignored. [PM-34222]
Updates the 7 existing assertions that required the client date to be forwarded, and adds coverage for a backdated, a future-dated, and an absent timestamp. Nine other tests supply a date without asserting on it and are left untouched, so they keep proving a client can still send one. [PM-34222]
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #8296 +/- ##
==========================================
+ Coverage 63.69% 63.71% +0.01%
==========================================
Files 2470 2470
Lines 105798 105840 +42
Branches 9562 9581 +19
==========================================
+ Hits 67389 67431 +42
Misses 36090 36090
Partials 2319 2319 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE This bugfix stops The two integration tests read the stored row back through Code Review DetailsNo findings. The offline-batch timestamp shift, the cipher-versus-organization ordering within a mixed batch, the deliberate absence of a feature flag, the |
The two integration tests added earlier could not fail. They posted events for a personal cipher, and BuildCipherEventMessageAsync drops cipher events with no OrganizationId, so no row was ever written and the tests only asserted a 200 response. They passed with the fix reverted. Replaces them with User_ClientExportedVault cases that read the stored event back through IEventRepository and assert the saved Date is server receipt time. Reverting a call site now fails exactly these two tests. Also documents that EventModel.Date is accepted and deliberately ignored, so the unused property does not read as a missed call site. [PM-34222]
🎟️ Tracking
PM-34222
📔 Objective
Organization event logs mixed two clocks. Server-originated events (create item, invite user) used the server's UTC clock, while client-originated events (copy password, view item, autofill, export vault) carried a timestamp generated on the user's device and were stored verbatim. On a device whose clock was a few minutes slow, the log showed events out of causal order: the reported case shows an item's password copied roughly three and a half minutes before the item itself was created.
CollectControllerwas the only place a client-controlled timestamp entered the event pipeline. Every otherIEventServicecaller already lets the service stamp the event, so this drops the client-supplied date at those five call sites andEventService's existingDateTime.UtcNowfallback applies per event, putting the whole log on one clock.This is also an audit-log integrity fix
Worth calling out separately, because it is the stronger reason to ship this. Before this change there was no validation of any kind on the incoming
date, so any authenticated user could choose the timestamp on their own audit entries:Cipher_ClientCopiedPassword,User_ClientExportedVault,Organization_ClientExportedVault. A user could backdate an export to bury it among older activity, or push it far enough into the future that it never appears in the event log's date-range query at all. That is log forgery by the actor the log exists to record, and it is now closed.This matches the design intent already documented for event logging, that server-side events are preferred because they cannot be circumvented by modifying the client.
Other defects fixed
datepreviously bound toDateTime.MinValueand was stored as a year-1 event.Trade-offs
Events queued on a client that was offline are now recorded at the time the server received them rather than the time the device claims. The upload interval is 60 seconds, so in the normal case this shifts a timestamp by under a minute, well inside the error it removes.
Ordering within a batch of cipher events is preserved, because the fallback is evaluated per event rather than per batch. Note this does not extend to a mixed batch: cipher events are accumulated and flushed after the loop while user and organization events are awaited inline, so a cipher event followed by an organization event will stamp the organization event first. The distortion is milliseconds, against the minutes it replaces.
An alternative that preserves the exact gaps between all events in a batch was considered and rejected as roughly four times the code on an audit path, with a new failure mode (ordering can invert between consecutive batches, which this approach never does).
No feature flag: carrying two timestamp semantics simultaneously would produce a log that is neither. Rollback is a revert. Existing rows written with a skewed device timestamp keep their values, as the true time is not recoverable.
EventModel.Dateis kept so existing clients continue to work unchanged, and is now documented as accepted-and-ignored.Consumers of these events through SIEM or webhook integrations will see the
datefield change meaning for client-originated events.🧪 Testing
Dateis server receipt time, by reading the event back throughIEventRepository. Reverting any call site fails exactly those two tests.datefield at all.📸 Screenshots
Not applicable, no UI changes.