Files
server/util/Migrator/DbScripts/2026-05-13_01_AlterUserCreateAndUpdate.sql
Jared Snider f3e4f5cd4e Auth/PM-37165 - Add Last API Key Rotated Date to User (#7634)
* PM-37165 - Add LastApiKeyRotationDate column to User

Adds a nullable DATETIME2(7) LastApiKeyRotationDate column on the User
table alongside the other Last*Date audit columns. Covers the MSSQL
table, view, User_Create / User_Update stored procedures (new optional
parameter, EDD-safe with default NULL), the SSDT source-of-truth, and
EF migrations for MySql, Postgres, and Sqlite.

Repository round-trip integration tests verify that CreateAsync
defaults the column to NULL and ReplaceAsync persists it across all
four providers.

* PM-37165 - Add RotateUserApiKeyCommand under Auth/UserFeatures

Extracts user API key rotation out of UserService into a new CQS
command at src/Core/Auth/UserFeatures/UserApiKey/, mirroring the
existing decomposition pattern for other Auth user features. The
command generates a new 30-char ApiKey, bumps RevisionDate, sets
LastApiKeyRotationDate, and persists via IUserRepository.ReplaceAsync.

Adds the PM37165_RotateUserApiKeyCommand feature flag so the new path
can be rolled out behind a flag in a follow-up commit. Registers the
command via AddUserApiKeyCommands inside AddUserServices.

Unit tests verify the command assigns a fresh key, updates both
RevisionDate and LastApiKeyRotationDate to the same recent UTC value,
and calls ReplaceAsync exactly once.

* PM-37165 - Flag-gate rotate-api-key endpoint to new command

Wires AccountsController.RotateApiKey to dispatch between
IRotateUserApiKeyCommand (flag on) and the legacy
UserService.RotateApiKeyAsync (flag off) based on
PM37165_RotateUserApiKeyCommand. Both paths preserve the existing
auth and secret-verification guards, which run before the flag
branch.

Marks IUserService.RotateApiKeyAsync and its implementation [Obsolete]
pointing callers at IRotateUserApiKeyCommand, with TODOs tying their
removal to the flag cleanup. The body of the legacy method is
deliberately unchanged so it does NOT write LastApiKeyRotationDate
while the flag is off; that genuinely gates the new behavior so the
ramp is observable and reversible. The single remaining call site
(the controller fallback) is wrapped in #pragma warning disable
CS0618 so the attribute continues to flag any new callers.

Tests:
- AccountsControllerTests: dispatch tests for both flag states; the
  auth and bad-secret guard tests are parameterized over flag state.
  Pre-existing typo in two tests that called _sut.ApiKey() instead of
  _sut.RotateApiKey() is fixed.
- UserServiceTests: regression test locks in the legacy non-write
  behavior so it cannot drift before the flag is removed.
- AccountsControllerTest (integration): three endpoint tests cover
  flag-off (LastApiKeyRotationDate stays NULL), flag-on (column is
  populated), and bad-secret over both flag states (no rotation
  occurs).

Each flag-state-specific test carries a TODO breadcrumb describing
the exact rename or deletion when the flag is cleaned up.

* PM-37165 - Tweak comment

* PM-37165 - Move LastApiKeyRotationDate to end of User schema

Append the new column to the end of User.sql, UserView.sql, the
matching CREATE OR ALTER VIEW in the migrator script, and the User
entity so SSDT mirrors what ALTER TABLE ADD produces in production.
2026-05-14 14:31:16 -04:00

274 lines
8.1 KiB
Transact-SQL

CREATE OR ALTER PROCEDURE [dbo].[User_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@Name NVARCHAR(50),
@Email NVARCHAR(256),
@EmailVerified BIT,
@MasterPassword NVARCHAR(300),
@MasterPasswordHint NVARCHAR(50),
@Culture NVARCHAR(10),
@SecurityStamp NVARCHAR(50),
@TwoFactorProviders NVARCHAR(MAX),
@TwoFactorRecoveryCode NVARCHAR(32),
@EquivalentDomains NVARCHAR(MAX),
@ExcludedGlobalEquivalentDomains NVARCHAR(MAX),
@AccountRevisionDate DATETIME2(7),
@Key NVARCHAR(MAX),
@PublicKey NVARCHAR(MAX),
@PrivateKey NVARCHAR(MAX),
@Premium BIT,
@PremiumExpirationDate DATETIME2(7),
@RenewalReminderDate DATETIME2(7),
@Storage BIGINT,
@MaxStorageGb SMALLINT,
@Gateway TINYINT,
@GatewayCustomerId VARCHAR(50),
@GatewaySubscriptionId VARCHAR(50),
@ReferenceData VARCHAR(MAX),
@LicenseKey VARCHAR(100),
@Kdf TINYINT,
@KdfIterations INT,
@KdfMemory INT = NULL,
@KdfParallelism INT = NULL,
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@ApiKey VARCHAR(30),
@ForcePasswordReset BIT = 0,
@UsesKeyConnector BIT = 0,
@FailedLoginCount INT = 0,
@LastFailedLoginDate DATETIME2(7),
@AvatarColor VARCHAR(7) = NULL,
@LastPasswordChangeDate DATETIME2(7) = NULL,
@LastKdfChangeDate DATETIME2(7) = NULL,
@LastKeyRotationDate DATETIME2(7) = NULL,
@LastEmailChangeDate DATETIME2(7) = NULL,
@VerifyDevices BIT = 1,
@SecurityState VARCHAR(MAX) = NULL,
@SecurityVersion INT = NULL,
@SignedPublicKey VARCHAR(MAX) = NULL,
@V2UpgradeToken VARCHAR(MAX) = NULL,
@MasterPasswordSalt NVARCHAR(256) = NULL,
@LastApiKeyRotationDate DATETIME2(7) = NULL
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[User]
(
[Id],
[Name],
[Email],
[EmailVerified],
[MasterPassword],
[MasterPasswordHint],
[Culture],
[SecurityStamp],
[TwoFactorProviders],
[TwoFactorRecoveryCode],
[EquivalentDomains],
[ExcludedGlobalEquivalentDomains],
[AccountRevisionDate],
[Key],
[PublicKey],
[PrivateKey],
[Premium],
[PremiumExpirationDate],
[RenewalReminderDate],
[Storage],
[MaxStorageGb],
[Gateway],
[GatewayCustomerId],
[GatewaySubscriptionId],
[ReferenceData],
[LicenseKey],
[Kdf],
[KdfIterations],
[CreationDate],
[RevisionDate],
[ApiKey],
[ForcePasswordReset],
[UsesKeyConnector],
[FailedLoginCount],
[LastFailedLoginDate],
[AvatarColor],
[KdfMemory],
[KdfParallelism],
[LastPasswordChangeDate],
[LastKdfChangeDate],
[LastKeyRotationDate],
[LastEmailChangeDate],
[VerifyDevices],
[SecurityState],
[SecurityVersion],
[SignedPublicKey],
[MaxStorageGbIncreased],
[V2UpgradeToken],
[MasterPasswordSalt],
[LastApiKeyRotationDate]
)
VALUES
(
@Id,
@Name,
@Email,
@EmailVerified,
@MasterPassword,
@MasterPasswordHint,
@Culture,
@SecurityStamp,
@TwoFactorProviders,
@TwoFactorRecoveryCode,
@EquivalentDomains,
@ExcludedGlobalEquivalentDomains,
@AccountRevisionDate,
@Key,
@PublicKey,
@PrivateKey,
@Premium,
@PremiumExpirationDate,
@RenewalReminderDate,
@Storage,
@MaxStorageGb,
@Gateway,
@GatewayCustomerId,
@GatewaySubscriptionId,
@ReferenceData,
@LicenseKey,
@Kdf,
@KdfIterations,
@CreationDate,
@RevisionDate,
@ApiKey,
@ForcePasswordReset,
@UsesKeyConnector,
@FailedLoginCount,
@LastFailedLoginDate,
@AvatarColor,
@KdfMemory,
@KdfParallelism,
@LastPasswordChangeDate,
@LastKdfChangeDate,
@LastKeyRotationDate,
@LastEmailChangeDate,
@VerifyDevices,
@SecurityState,
@SecurityVersion,
@SignedPublicKey,
@MaxStorageGb,
@V2UpgradeToken,
@MasterPasswordSalt,
@LastApiKeyRotationDate
)
END
GO
CREATE OR ALTER PROCEDURE [dbo].[User_Update]
@Id UNIQUEIDENTIFIER,
@Name NVARCHAR(50),
@Email NVARCHAR(256),
@EmailVerified BIT,
@MasterPassword NVARCHAR(300),
@MasterPasswordHint NVARCHAR(50),
@Culture NVARCHAR(10),
@SecurityStamp NVARCHAR(50),
@TwoFactorProviders NVARCHAR(MAX),
@TwoFactorRecoveryCode NVARCHAR(32),
@EquivalentDomains NVARCHAR(MAX),
@ExcludedGlobalEquivalentDomains NVARCHAR(MAX),
@AccountRevisionDate DATETIME2(7),
@Key NVARCHAR(MAX),
@PublicKey NVARCHAR(MAX),
@PrivateKey NVARCHAR(MAX),
@Premium BIT,
@PremiumExpirationDate DATETIME2(7),
@RenewalReminderDate DATETIME2(7),
@Storage BIGINT,
@MaxStorageGb SMALLINT,
@Gateway TINYINT,
@GatewayCustomerId VARCHAR(50),
@GatewaySubscriptionId VARCHAR(50),
@ReferenceData VARCHAR(MAX),
@LicenseKey VARCHAR(100),
@Kdf TINYINT,
@KdfIterations INT,
@KdfMemory INT = NULL,
@KdfParallelism INT = NULL,
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@ApiKey VARCHAR(30),
@ForcePasswordReset BIT = 0,
@UsesKeyConnector BIT = 0,
@FailedLoginCount INT,
@LastFailedLoginDate DATETIME2(7),
@AvatarColor VARCHAR(7),
@LastPasswordChangeDate DATETIME2(7) = NULL,
@LastKdfChangeDate DATETIME2(7) = NULL,
@LastKeyRotationDate DATETIME2(7) = NULL,
@LastEmailChangeDate DATETIME2(7) = NULL,
@VerifyDevices BIT = 1,
@SecurityState VARCHAR(MAX) = NULL,
@SecurityVersion INT = NULL,
@SignedPublicKey VARCHAR(MAX) = NULL,
@V2UpgradeToken VARCHAR(MAX) = NULL,
@MasterPasswordSalt NVARCHAR(256) = NULL,
@LastApiKeyRotationDate DATETIME2(7) = NULL
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[User]
SET
[Name] = @Name,
[Email] = @Email,
[EmailVerified] = @EmailVerified,
[MasterPassword] = @MasterPassword,
[MasterPasswordHint] = @MasterPasswordHint,
[Culture] = @Culture,
[SecurityStamp] = @SecurityStamp,
[TwoFactorProviders] = @TwoFactorProviders,
[TwoFactorRecoveryCode] = @TwoFactorRecoveryCode,
[EquivalentDomains] = @EquivalentDomains,
[ExcludedGlobalEquivalentDomains] = @ExcludedGlobalEquivalentDomains,
[AccountRevisionDate] = @AccountRevisionDate,
[Key] = @Key,
[PublicKey] = @PublicKey,
[PrivateKey] = @PrivateKey,
[Premium] = @Premium,
[PremiumExpirationDate] = @PremiumExpirationDate,
[RenewalReminderDate] = @RenewalReminderDate,
[Storage] = @Storage,
[MaxStorageGb] = @MaxStorageGb,
[Gateway] = @Gateway,
[GatewayCustomerId] = @GatewayCustomerId,
[GatewaySubscriptionId] = @GatewaySubscriptionId,
[ReferenceData] = @ReferenceData,
[LicenseKey] = @LicenseKey,
[Kdf] = @Kdf,
[KdfIterations] = @KdfIterations,
[KdfMemory] = @KdfMemory,
[KdfParallelism] = @KdfParallelism,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[ApiKey] = @ApiKey,
[ForcePasswordReset] = @ForcePasswordReset,
[UsesKeyConnector] = @UsesKeyConnector,
[FailedLoginCount] = @FailedLoginCount,
[LastFailedLoginDate] = @LastFailedLoginDate,
[AvatarColor] = @AvatarColor,
[LastPasswordChangeDate] = @LastPasswordChangeDate,
[LastKdfChangeDate] = @LastKdfChangeDate,
[LastKeyRotationDate] = @LastKeyRotationDate,
[LastEmailChangeDate] = @LastEmailChangeDate,
[VerifyDevices] = @VerifyDevices,
[SecurityState] = @SecurityState,
[SecurityVersion] = @SecurityVersion,
[SignedPublicKey] = @SignedPublicKey,
[MaxStorageGbIncreased] = @MaxStorageGb,
[V2UpgradeToken] = @V2UpgradeToken,
[MasterPasswordSalt] = @MasterPasswordSalt,
[LastApiKeyRotationDate] = @LastApiKeyRotationDate
WHERE
[Id] = @Id
END
GO