Files
server/util/Migrator/DbScripts/2026-05-14_00_AddDeviceClientVersionAndUpdateLastActivitySp.sql
Jared Snider 450159a1e2 Auth/PM-37166 - Devices - add client version (#7632)
* PM-37166 - Add ClientVersion to Device entity and repository contract

* PM-37166 - Add ClientVersion SQL schema and refactor bump stored procedures

* PM-37166 - Implement combined bump in repositories and add EF migrations

EF snapshot regeneration also absorbs Collection / CollectionGroup /
CollectionUser namespace moves (Bit.Infrastructure.EntityFramework.Models
-> Bit.Infrastructure.EntityFramework.AdminConsole.Models) that were left
un-regenerated by PR #7523 (PM-35489). Namespace-only, no SQL impact;
flagged with the AC team for awareness.

* PM-37166 - Replace DeviceLastActivityCacheService with DeviceDataCacheService

* PM-37166 - Replace BumpDeviceLastActivityDateCommand with BumpDeviceDataCommand

* PM-37166 - Pass ClientVersion through identity request validators

* PM-37166 - Align migration script with SQL style guide

Refresh Device_ReadBy* sprocs after DeviceView change so their cached
schema picks up ClientVersion, swap retired-sproc drops to
DROP PROCEDURE IF EXISTS, and tighten the ALTER TABLE indent in step 1.

* PM-37166 - Rename BumpData to UpdateLastActivity across device write pathway

The "BumpData" naming was vague — "data" named a category, not the thing
being written. Rename to "UpdateLastActivity" everywhere: SP, repositories,
command, cache, validators, tests. "Last activity" names the event of the
device's most recent appearance; LastActivityDate (when) and ClientVersion
(what was running) are facts we observed about that event. ClientVersion is
treated as a property of the activity event rather than an independent value,
so future last-observed properties (last IP, OS, etc.) slot in without renaming.

The SQL layer uses Update* per architect guidance on bitwarden/server#7302;
the Bump* SPs in this codebase are legacy and not being extended. The
extensibility note lives on IUpdateDeviceLastActivityCommand with short
pointers from the SP, repo, and cache.

Cache key prefix changes from device:data: to device:last-activity: — safe
because the cache is only a write-suppression optimization (SP guards ensure
correctness) and entries TTL out within 24h.

Migration renamed to 2026-05-14 to reflect the rewrite.

* PM-37166 - Add UTF-8 BOM to device last activity cache files

Aligns file encoding with the repo's .editorconfig (charset = utf-8-bom for .cs) so dotnet format --verify-no-changes passes.

* PM-37166 - Compare LastActivityDate at second precision in device creation test

GetManyByUserIdWithDeviceAuth_ReturnsLastActivityDate_ForNewDeviceAsync was
flaking on SqlServer: Dapper binds DateTime params as legacy `datetime`
(~3.33ms granularity), so the entity initializer's UtcNow can be rounded a
few ms earlier than the in-memory `beforeCreation` capture, making a strict
>= comparison occasionally false. Truncate both sides to the second to
absorb that drift while still rejecting stale or defaulted values.

* PM-37166 - Rename Device.ClientVersion EF migration

Renames migration class/files from AddDeviceClientVersionRefactorDeviceDataBump
to AddDeviceClientVersion to drop stale "Bump" terminology and the misleading
"RefactorDeviceData" prefix. The EF migration only adds the ClientVersion
column; the BumpData -> UpdateLastActivity SP refactor lives in MSSQL .sql
files and has no EF representation.

* PM-37166 - Document null-is-no-op semantics for ClientVersion on IUpdateDeviceLastActivityCommand

Tighten the interface-level summary and add a <param> note clarifying that
a null clientVersion is treated as "no opinion" and will not clear an
existing stored value.

* PM-37166 - Regenerate Device.ClientVersion EF migration on post-#7634 baseline

PR #7634 merged AddLastApiKeyRotationDateToUserTable into main while this
branch was open. The prior AddDeviceClientVersion migration's frozen model
snapshot (its .Designer.cs) was generated before that PR landed, so it did
not include User.LastApiKeyRotationDate. Applying migrations incrementally
against that stale snapshot would produce an inconsistent model graph.

Regenerated AddDeviceClientVersion on top of the merged-from-main baseline
so the new .Designer.cs files include both columns. The migration body
itself still only adds Device.ClientVersion; the top-level
DatabaseContextModelSnapshot.cs files were already correct from git's
three-way merge.

New timestamps (20260514192xxx) come after the User migration
(20260514011xxx), preserving migration order.

* PM-37166 - util/Migrator/DbScripts/2026-05-14_00_AddDeviceClientVersionAndUpdateLastActivitySp.sql - fix wrong comment

* PM-37166 - Bump Device.ClientVersion column width from 20 to 43

43 is the upper bound of Version.ToString() for any input parseable by
Version.TryParse — four Int32 components (Int32.MaxValue = 10 digits)
joined by 3 dots. Sizing to the type's mathematical max prevents
SQL Server error 8152 on malformed/hostile Bitwarden-Client-Version
headers without paying the cost of normalization at the call sites.

Real Bitwarden CalVer (YYYY.M.B) remains well within bounds at ~9 chars.

- Device.cs [MaxLength] + entity doc comment
- SSDT table + 4 stored procedures
- Cloud migration ALTER TABLE + SP parameters
- EF migrations regenerated for MySQL / Postgres / SQLite

* PM-37166 - Defer dropping old single-column UpdateLastActivityDate SPs to follow-up

Server and DB deploys are decoupled, so dropping the old SPs in the same migration that
introduces the new combined ones would break server rollback. Per discussion on PR #7632:

- Remove DROP PROCEDURE statements from the migration; replace with a note explaining the deferral.
- Restore the old Device_UpdateLastActivityDate{ById,ByIdentifierUserId}.sql files in src/Sql/dbo
  so the SSDT source-of-truth stays aligned with deployed schema (EDD).

A follow-up ticket will drop the old SPs and delete the .sql files together once we're
confident no deployed server version still calls them.

* PM-37166 - Pass @LastActivityDate into Device_UpdateLastActivity SPs

Bitwarden convention is to compute timestamps in the application layer
and pass them as DATETIME2(7) params, not call GETUTCDATE() inside SPs.
Dapper repo now computes DateTime.UtcNow locally (matching the EF repo
and UserRepository.cs precedent) and passes LastActivityDate through.
2026-05-14 20:22:13 -04:00

259 lines
9.7 KiB
Transact-SQL

-- PM-37166 — Add ClientVersion column to Device and refactor the LastActivityDate update pathway
-- into a combined "UpdateLastActivity" pathway that handles both columns in a single DB round trip.
-- "Last activity" names the event of the device's most recent appearance; LastActivityDate and
-- ClientVersion are the facts we observed about that event. See IUpdateDeviceLastActivityCommand
-- for the contract-level extensibility note (additional last-observed properties slot in here).
-- 1. Add the ClientVersion column. Guarded so reruns are safe.
IF COL_LENGTH('[dbo].[Device]', 'ClientVersion') IS NULL
BEGIN
ALTER TABLE [dbo].[Device]
ADD [ClientVersion] NVARCHAR(43) NULL
END
GO
-- 2. DeviceView mirrors all columns — refresh so it picks up ClientVersion.
CREATE OR ALTER VIEW [dbo].[DeviceView]
AS
SELECT
*
FROM
[dbo].[Device]
GO
-- 2a. Refresh read sprocs that consume DeviceView so their cached schema picks up ClientVersion.
-- The write sprocs (Device_Create / Device_Update / Device_UpdateLastActivity*) are recreated below,
-- so they pick up the new column naturally and don't need a refresh.
IF OBJECT_ID('[dbo].[Device_ReadById]') IS NOT NULL
BEGIN
EXECUTE sp_refreshsqlmodule N'[dbo].[Device_ReadById]'
END
GO
IF OBJECT_ID('[dbo].[Device_ReadByIdentifierUserId]') IS NOT NULL
BEGIN
EXECUTE sp_refreshsqlmodule N'[dbo].[Device_ReadByIdentifierUserId]'
END
GO
IF OBJECT_ID('[dbo].[Device_ReadByUserId]') IS NOT NULL
BEGIN
EXECUTE sp_refreshsqlmodule N'[dbo].[Device_ReadByUserId]'
END
GO
IF OBJECT_ID('[dbo].[Device_ReadByIdentifier]') IS NOT NULL
BEGIN
EXECUTE sp_refreshsqlmodule N'[dbo].[Device_ReadByIdentifier]'
END
GO
-- 3. Device_Create: accept @ClientVersion and include it in the INSERT list.
CREATE OR ALTER PROCEDURE [dbo].[Device_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@UserId UNIQUEIDENTIFIER,
@Name NVARCHAR(50),
@Type TINYINT,
@Identifier NVARCHAR(50),
@PushToken NVARCHAR(255),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@EncryptedUserKey VARCHAR(MAX) = NULL,
@EncryptedPublicKey VARCHAR(MAX) = NULL,
@EncryptedPrivateKey VARCHAR(MAX) = NULL,
@Active BIT = 1,
@LastActivityDate DATETIME2(7) = NULL,
@ClientVersion NVARCHAR(43) = NULL
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[Device]
(
[Id],
[UserId],
[Name],
[Type],
[Identifier],
[PushToken],
[CreationDate],
[RevisionDate],
[EncryptedUserKey],
[EncryptedPublicKey],
[EncryptedPrivateKey],
[Active],
[LastActivityDate],
[ClientVersion]
)
VALUES
(
@Id,
@UserId,
@Name,
@Type,
@Identifier,
@PushToken,
@CreationDate,
@RevisionDate,
@EncryptedUserKey,
@EncryptedPublicKey,
@EncryptedPrivateKey,
@Active,
@LastActivityDate,
@ClientVersion
)
END
GO
-- 4. Device_Update: accept @ClientVersion with NULL-passthrough guard.
CREATE OR ALTER PROCEDURE [dbo].[Device_Update]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@Name NVARCHAR(50),
@Type TINYINT,
@Identifier NVARCHAR(50),
@PushToken NVARCHAR(255),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@EncryptedUserKey VARCHAR(MAX) = NULL,
@EncryptedPublicKey VARCHAR(MAX) = NULL,
@EncryptedPrivateKey VARCHAR(MAX) = NULL,
@Active BIT = 1,
@LastActivityDate DATETIME2(7) = NULL,
@ClientVersion NVARCHAR(43) = NULL
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[Device]
SET
[UserId] = @UserId,
[Name] = @Name,
[Type] = @Type,
[Identifier] = @Identifier,
[PushToken] = @PushToken,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[EncryptedUserKey] = @EncryptedUserKey,
[EncryptedPublicKey] = @EncryptedPublicKey,
[EncryptedPrivateKey] = @EncryptedPrivateKey,
[Active] = @Active,
-- LastActivityDate only moves forward. Two scenarios could silently clobber a valid update:
-- 1. NULL passthrough: a general save that does not intend to touch LastActivityDate passes NULL
-- (the default); we must not overwrite an existing value with NULL.
-- 2. Stale non-null overwrite: a thread that loaded the device before a concurrent
-- last-activity update fires may call SaveAsync with an older date; we must not
-- clobber the fresher DB value.
-- The CASE expression handles both: LastActivityDate is updated only when the incoming value is
-- strictly greater than the current DB value (ISNULL baseline of '1900-01-01' handles NULL DB values).
[LastActivityDate] = CASE
WHEN @LastActivityDate > ISNULL([LastActivityDate], '1900-01-01') THEN @LastActivityDate
ELSE [LastActivityDate]
END,
-- ClientVersion is value-equality based, not forward-only — downgrades are valid (e.g. a user
-- reverts a desktop install). We only need NULL passthrough so unrelated SaveAsync calls (that
-- don't intend to touch ClientVersion) don't clobber the stored value with NULL.
[ClientVersion] = ISNULL(@ClientVersion, [ClientVersion])
WHERE
[Id] = @Id
END
GO
-- 5. Device_UpdateLastActivityById: new combined SP. Replaces Device_UpdateLastActivityDateById.
CREATE OR ALTER PROCEDURE [dbo].[Device_UpdateLastActivityById]
@Id UNIQUEIDENTIFIER,
@LastActivityDate DATETIME2(7),
@ClientVersion NVARCHAR(43) = NULL
AS
BEGIN
SET NOCOUNT ON
-- "Last activity" names the *event* of the device's most recent appearance, not just one column.
-- The fields written here are the set of facts we observed about that event:
-- LastActivityDate — when it occurred (day-level idempotence: only move forward to today).
-- ClientVersion — what the client was running at the time (value-level idempotence: only
-- write when @ClientVersion is non-null and differs from the stored value).
-- ClientVersion is treated as a property of the activity event rather than an independent value.
-- Additional last-observed properties (e.g. last IP, OS) would slot in here without renaming.
-- See IUpdateDeviceLastActivityCommand for the contract-level extensibility note.
--
-- One UPDATE handles both columns. The WHERE clause ensures we only issue a write when at least
-- one column actually needs changing. The application-layer cache is the primary protection
-- against redundant calls; these SP-side guards are a safety net in case the cache is unavailable
-- or evicted.
UPDATE
[dbo].[Device]
SET
[LastActivityDate] =
CASE
WHEN [LastActivityDate] IS NULL OR CAST([LastActivityDate] AS DATE) < CAST(@LastActivityDate AS DATE)
THEN @LastActivityDate
ELSE [LastActivityDate]
END,
[ClientVersion] =
CASE
WHEN @ClientVersion IS NOT NULL AND ([ClientVersion] IS NULL OR [ClientVersion] <> @ClientVersion)
THEN @ClientVersion
ELSE [ClientVersion]
END
WHERE
[Id] = @Id
AND (
[LastActivityDate] IS NULL
OR CAST([LastActivityDate] AS DATE) < CAST(@LastActivityDate AS DATE)
OR (@ClientVersion IS NOT NULL AND ([ClientVersion] IS NULL OR [ClientVersion] <> @ClientVersion))
)
END
GO
-- 6. Device_UpdateLastActivityByIdentifierUserId: new combined SP. Replaces
-- Device_UpdateLastActivityDateByIdentifierUserId.
CREATE OR ALTER PROCEDURE [dbo].[Device_UpdateLastActivityByIdentifierUserId]
@Identifier NVARCHAR(50),
@UserId UNIQUEIDENTIFIER,
@LastActivityDate DATETIME2(7),
@ClientVersion NVARCHAR(43) = NULL
AS
BEGIN
SET NOCOUNT ON
-- Both @Identifier and @UserId are required: Identifier is unique per user, not globally
-- (unique constraint UX_Device_UserId_Identifier is on (UserId, Identifier)). Including
-- UserId scopes the write to the authenticated user's device and ensures the query hits
-- UX_Device_UserId_Identifier; without it the query falls back to IX_Device_Identifier,
-- which is non-unique and would require a scan across all users.
--
-- See Device_UpdateLastActivityById for the event-oriented naming rationale and per-column guards.
UPDATE
[dbo].[Device]
SET
[LastActivityDate] =
CASE
WHEN [LastActivityDate] IS NULL OR CAST([LastActivityDate] AS DATE) < CAST(@LastActivityDate AS DATE)
THEN @LastActivityDate
ELSE [LastActivityDate]
END,
[ClientVersion] =
CASE
WHEN @ClientVersion IS NOT NULL AND ([ClientVersion] IS NULL OR [ClientVersion] <> @ClientVersion)
THEN @ClientVersion
ELSE [ClientVersion]
END
WHERE
[Identifier] = @Identifier
AND [UserId] = @UserId
AND (
[LastActivityDate] IS NULL
OR CAST([LastActivityDate] AS DATE) < CAST(@LastActivityDate AS DATE)
OR (@ClientVersion IS NOT NULL AND ([ClientVersion] IS NULL OR [ClientVersion] <> @ClientVersion))
)
END
GO
-- Note: the old single-column SPs (Device_UpdateLastActivityDateById /
-- Device_UpdateLastActivityDateByIdentifierUserId) are intentionally NOT dropped here.
-- Server and DB deploys are decoupled, so the server could roll back to a version that
-- still calls the old SPs after this migration has run. A follow-up ticket will drop
-- them in a later migration once we're confident no deployed server version calls them.