mirror of
https://github.com/bitwarden/server.git
synced 2026-06-01 01:55:55 -05:00
* PM-4517 - Add LastActivityDate to Device entity, interfaces, DTOs, and response models
Adds the LastActivityDate nullable DateTime property to the Device entity,
IDeviceRepository interface (BumpLastActivityDateByIdAsync and
BumpLastActivityDateByIdentifierAsync), DeviceAuthDetails DTO,
DeviceResponseModel, DeviceAuthRequestResponseModel, and the
DevicesLastActivityDate feature flag key in Constants.
* PM-4517 - Add BumpDeviceLastActivityDateCommand with distributed cache guard
Adds IBumpDeviceLastActivityDateCommand and IDeviceLastActivityCacheService
interfaces with their implementations. The cache service uses the persistent
keyed IDistributedCache (Cosmos DB in cloud, SQL Server in self-hosted) with
a 48h TTL to guard against redundant DB writes within the same calendar day.
Moves device DI registration into a consolidated AddDeviceServices() extension.
* PM-4517 - Add LastActivityDate SQL schema, stored procedures, and MSSQL migration
Adds LastActivityDate DATETIME2 column to the Device table. Updates Device_Create
and Device_Update stored procedures. Adds Device_BumpLastActivityDateById and
Device_BumpLastActivityDateByIdentifier stored procedures with a CAST AS DATE
guard as a fallback against redundant writes when the application-layer cache
is unavailable.
* PM-4517 - Implement LastActivityDate repository methods and EF migrations
Implements BumpLastActivityDateByIdAsync and BumpLastActivityDateByIdentifierAsync
in both Dapper (via stored procedures) and EF (via ExecuteUpdateAsync with a
date-level guard). Adds EF migrations for Postgres, SQLite, and MySQL.
* PM-4517 - Bump device LastActivityDate on login and refresh token
Wires IBumpDeviceLastActivityDateCommand into BaseRequestValidator (login path,
keyed on device.Id) and CustomTokenRequestValidator (refresh token path, keyed
on device identifier from subject claims). Both call sites are feature-flagged
behind DevicesLastActivityDate.
* PM-4517 - Move AddDeviceServices() to AddBaseServices alongside IDeviceService
Device services are not user features — co-locating them with IDeviceService
in AddBaseServices is more cohesive than nesting them inside AddUserServices.
* PM-4517 - Swallow transient LastActivityDate bump failures to prevent auth disruption
* PM-4517 - Fix DeviceAuthDetails Dapper constructor parameter order to match LastActivityDate column position
* PM-4517 - Add edge case tests for BumpDeviceLastActivityForRefreshAsync guard conditions
* PM-4517 - Add tests for BumpLastActivityDate flag-disabled, null-device, and happy-path cases
* PM-4517 - Add PM-34091 cleanup TODOs to all DevicesLastActivityDate feature flag sites
* PM-4517 - Refine PM-34091 cleanup TODOs and add missing feature flag disabled test for refresh path
* PM-4517 - Remove redundant LastActivityDate shadow property from DeviceAuthDetails
* PM-4517 - Use CultureInfo.InvariantCulture in date string formatting for CA1305
* PM-4517 - Make _bumpDeviceLastActivityDateCommand protected in base to remove duplicate field in derived class
* PM-4517 - Scope device last activity cache key by userId to prevent cross-user collisions
The Device table's unique constraint is (UserId, Identifier), not Identifier alone,
so two users can share the same device identifier (e.g. account switching in a browser).
Scoping the cache key to device:last-activity:{userId}:{identifier} ensures that a cache
hit for one user never suppresses a DB write for another.
Also adds userId to BumpByIdAsync signature and reorders params to be consistent with
BumpByIdentifierAsync(string identifier, Guid userId).
* PM-4517 - Widen try-catch in TryBumpDeviceLastActivityForRefreshAsync and add happy-path test
Renames BumpDeviceLastActivityForRefreshAsync to TryBumpDeviceLastActivityForRefreshAsync
to signal the swallow-on-error intent. Moves the try-catch to wrap the entire method body,
including GetSubjectId() which can throw InvalidOperationException, so no exception can
escape and disrupt token refresh. Also moves the XML doc comment to RecordActivityForInstallation
where it belongs, and adds a happy-path test verifying BumpByIdentifierAsync is called
with the correct identifier and userId.
* PM-4517 - Capture DateTime.UtcNow once in EF bump methods to ensure consistent timestamp
Avoids a minor inconsistency where the WHERE filter and SET clause could evaluate
DateTime.UtcNow at slightly different moments, aligning behavior with the SQL stored
procedures which use a single @RevisionDate parameter.
* PM-4517 - Preserve LastActivityDate on Device_Update when null to prevent regressions
Device_Update previously overwrote LastActivityDate unconditionally, meaning any unrelated
device update (push token rotation, trust changes, deactivation) could silently regress a
recently-bumped value. COALESCE preserves the existing DB value when NULL is passed, while
still allowing callers to set it in the same write by passing a non-NULL value. The EF
ReplaceAsync override applies the same semantics via IsModified = false. Integration test
added to cover the preserve-on-null behaviour across all DB providers.
* PM-4517 - Add docs
* PM-4517 - Adjust docs
* PM-4517 - Add test coverage for BumpLastActivityDateByIdentifierAsync
* PM-4517 - Per PR feedback, add docs on IDeviceLastActivityCacheService
* PM-4517 - Per PR feedback, adjust IBumpDeviceLastActivityDateCommand.BumpById to be bump by device instead b/c it has all what we need.
* PM-4517 - Per PR feedback, add tech debt ticket.
* PM-4517 - Rename BumpByIdentifierAsync to BumpByIdentifierAndUserIdAsync across the board.
* PM-4517 - Per PR feedback, adjust stored proc names to meet SQL style requirements
* PM-4517 - Replace COALESCE with CASE in Device_Update to prevent stale non-null LastActivityDate overwrites
* PM-4517 - Add EF repository feature parity for replace logic + test to ensure we don't run into this again.
* PM-4517 - Fix DB migration order after main merge.
* PM-4517 - Regenerate EF DB migrations
* PM-4517 - actually regenerate EF DB migrations
* PM-4517 - Add LastActivityDate to Device_ReadActiveWithPendingAuthRequestsByUserId and integration tests
220 lines
6.9 KiB
Transact-SQL
220 lines
6.9 KiB
Transact-SQL
IF COL_LENGTH('[dbo].[Device]', 'LastActivityDate') IS NULL
|
|
BEGIN
|
|
ALTER TABLE
|
|
[dbo].[Device]
|
|
ADD
|
|
[LastActivityDate] DATETIME2 (7) NULL
|
|
END
|
|
GO
|
|
|
|
CREATE OR ALTER VIEW [dbo].[DeviceView]
|
|
AS
|
|
SELECT
|
|
*
|
|
FROM
|
|
[dbo].[Device]
|
|
GO
|
|
|
|
CREATE OR ALTER PROCEDURE [dbo].[Device_ReadActiveWithPendingAuthRequestsByUserId]
|
|
@UserId UNIQUEIDENTIFIER,
|
|
@ExpirationMinutes INT
|
|
AS
|
|
BEGIN
|
|
SET NOCOUNT ON;
|
|
|
|
SELECT
|
|
D.[Id],
|
|
D.[UserId],
|
|
D.[Name],
|
|
D.[Type],
|
|
D.[Identifier],
|
|
D.[PushToken],
|
|
D.[CreationDate],
|
|
D.[RevisionDate],
|
|
D.[EncryptedUserKey],
|
|
D.[EncryptedPublicKey],
|
|
D.[EncryptedPrivateKey],
|
|
D.[Active],
|
|
D.[LastActivityDate],
|
|
AR.[Id] AS [AuthRequestId],
|
|
AR.[CreationDate] AS [AuthRequestCreationDate]
|
|
FROM
|
|
[dbo].[DeviceView] D
|
|
LEFT OUTER JOIN (
|
|
SELECT
|
|
[Id],
|
|
[CreationDate],
|
|
[RequestDeviceIdentifier],
|
|
[Approved],
|
|
ROW_NUMBER() OVER (PARTITION BY [RequestDeviceIdentifier] ORDER BY [CreationDate] DESC) AS rn
|
|
FROM
|
|
[dbo].[AuthRequestView]
|
|
WHERE
|
|
[Type] IN (0,1) -- AuthenticateAndUnlock and Unlock types only
|
|
AND [CreationDate] >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE()) -- Ensure the request hasn't expired
|
|
AND [UserId] = @UserId -- Requests for this user only
|
|
) AR -- This join will get the most recent request per device, regardless of approval status
|
|
ON D.[Identifier] = AR.[RequestDeviceIdentifier] AND AR.[rn] = 1 AND AR.[Approved] IS NULL -- Get only the most recent unapproved request per device
|
|
WHERE
|
|
D.[UserId] = @UserId -- Include only devices for this user
|
|
AND D.[Active] = 1; -- Include only active devices
|
|
END
|
|
GO
|
|
|
|
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
|
|
AS
|
|
BEGIN
|
|
SET NOCOUNT ON
|
|
|
|
INSERT INTO [dbo].[Device]
|
|
(
|
|
[Id],
|
|
[UserId],
|
|
[Name],
|
|
[Type],
|
|
[Identifier],
|
|
[PushToken],
|
|
[CreationDate],
|
|
[RevisionDate],
|
|
[EncryptedUserKey],
|
|
[EncryptedPublicKey],
|
|
[EncryptedPrivateKey],
|
|
[Active],
|
|
[LastActivityDate]
|
|
)
|
|
VALUES
|
|
(
|
|
@Id,
|
|
@UserId,
|
|
@Name,
|
|
@Type,
|
|
@Identifier,
|
|
@PushToken,
|
|
@CreationDate,
|
|
@RevisionDate,
|
|
@EncryptedUserKey,
|
|
@EncryptedPublicKey,
|
|
@EncryptedPrivateKey,
|
|
@Active,
|
|
@LastActivityDate
|
|
)
|
|
END
|
|
GO
|
|
|
|
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
|
|
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 bump:
|
|
-- 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 bump 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
|
|
WHERE
|
|
[Id] = @Id
|
|
END
|
|
GO
|
|
|
|
CREATE OR ALTER PROCEDURE [dbo].[Device_UpdateLastActivityDateById]
|
|
@Id UNIQUEIDENTIFIER
|
|
AS
|
|
BEGIN
|
|
SET NOCOUNT ON
|
|
|
|
-- Only update if LastActivityDate has never been set or was last set on a prior calendar day.
|
|
-- This acts as a fallback guard against redundant writes in case the application-layer cache
|
|
-- is unavailable or evicted. In normal operation the cache
|
|
-- prevents this procedure from being called more than once per device per day entirely.
|
|
-- Product only requires day-level granularity (today / this week / last week / etc.).
|
|
UPDATE
|
|
[dbo].[Device]
|
|
SET
|
|
[LastActivityDate] = GETUTCDATE()
|
|
WHERE
|
|
[Id] = @Id
|
|
AND (
|
|
[LastActivityDate] IS NULL
|
|
OR CAST([LastActivityDate] AS DATE) < CAST(GETUTCDATE() AS DATE)
|
|
)
|
|
END
|
|
GO
|
|
|
|
CREATE OR ALTER PROCEDURE [dbo].[Device_UpdateLastActivityDateByIdentifierUserId]
|
|
@Identifier NVARCHAR(50),
|
|
@UserId UNIQUEIDENTIFIER
|
|
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.
|
|
--
|
|
-- Only update if LastActivityDate has never been set or was last set on a prior calendar day.
|
|
-- This acts as a fallback guard against redundant writes in case the application-layer cache
|
|
-- is unavailable or evicted. In normal operation the cache
|
|
-- prevents this procedure from being called more than once per device per day entirely.
|
|
-- Product only requires day-level granularity (today / this week / last week / etc.).
|
|
UPDATE
|
|
[dbo].[Device]
|
|
SET
|
|
[LastActivityDate] = GETUTCDATE()
|
|
WHERE
|
|
[Identifier] = @Identifier
|
|
AND [UserId] = @UserId
|
|
AND (
|
|
[LastActivityDate] IS NULL
|
|
OR CAST([LastActivityDate] AS DATE) < CAST(GETUTCDATE() AS DATE)
|
|
)
|
|
END
|
|
GO
|