diff --git a/src/Api/Dirt/Controllers/CipherBrowserHealthExclusionsController.cs b/src/Api/Dirt/Controllers/CipherBrowserHealthExclusionsController.cs
new file mode 100644
index 000000000000..c6d38907253d
--- /dev/null
+++ b/src/Api/Dirt/Controllers/CipherBrowserHealthExclusionsController.cs
@@ -0,0 +1,33 @@
+using Bit.Core;
+using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
+using Bit.Core.Services;
+using Bitwarden.Server.Sdk.Features;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Bit.Api.Dirt.Controllers;
+
+[Route("ciphers")]
+[Authorize("Application")]
+public class CipherBrowserHealthExclusionsController : Controller
+{
+ private readonly IUserService _userService;
+ private readonly IExcludeCipherFromBrowserHealthCommand _excludeCipherFromBrowserHealthCommand;
+
+ public CipherBrowserHealthExclusionsController(
+ IUserService userService,
+ IExcludeCipherFromBrowserHealthCommand excludeCipherFromBrowserHealthCommand)
+ {
+ _userService = userService;
+ _excludeCipherFromBrowserHealthCommand = excludeCipherFromBrowserHealthCommand;
+ }
+
+ /// Excludes the caller's personal login from browser health.
+ [HttpPut("{id}/exclude-from-browser-health")]
+ [RequireFeature(FeatureFlagKeys.HealthReportExclusions)]
+ public async Task PutExcludeFromBrowserHealthAsync(Guid id)
+ {
+ var userId = _userService.GetProperUserId(User)!.Value;
+ await _excludeCipherFromBrowserHealthCommand.ExcludeAsync(id, userId);
+ }
+}
diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs
index 3adbbb4748e3..4ad2acbacd1f 100644
--- a/src/Api/Vault/Models/Response/CipherResponseModel.cs
+++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs
@@ -296,6 +296,7 @@ protected CipherResponseModel(
Favorite = cipher.Favorite;
Edit = cipher.Edit;
ArchivedDate = cipher.ArchivedDate;
+ ExcludedFromBrowserHealth = cipher.ExcludedFromBrowserHealth;
ViewPassword = cipher.ViewPassword;
Permissions = new CipherPermissionsResponseModel(user, cipher, organizationAbility);
}
@@ -312,6 +313,7 @@ public static CipherResponseModel From(FullCipherAccess access, CipherDetails ci
public bool Edit { get; set; }
public bool ViewPassword { get; set; }
public DateTime? ArchivedDate { get; set; }
+ public bool ExcludedFromBrowserHealth { get; set; }
public CipherPermissionsResponseModel Permissions { get; set; }
}
diff --git a/src/Core/Dirt/Reports/ReportFeatures/ExcludeCipherFromBrowserHealthCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/ExcludeCipherFromBrowserHealthCommand.cs
new file mode 100644
index 000000000000..57fdf8bfce67
--- /dev/null
+++ b/src/Core/Dirt/Reports/ReportFeatures/ExcludeCipherFromBrowserHealthCommand.cs
@@ -0,0 +1,40 @@
+using Bit.Core.Billing.Premium.Queries;
+using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
+using Bit.Core.Dirt.Repositories;
+using Bit.Core.Exceptions;
+using Bit.Core.Platform.Push;
+
+namespace Bit.Core.Dirt.Reports.ReportFeatures;
+
+public class ExcludeCipherFromBrowserHealthCommand : IExcludeCipherFromBrowserHealthCommand
+{
+ private readonly ICipherBrowserHealthExclusionRepository _cipherBrowserHealthExclusionRepository;
+ private readonly IHasPremiumAccessQuery _hasPremiumAccessQuery;
+ private readonly IPushNotificationService _pushService;
+
+ public ExcludeCipherFromBrowserHealthCommand(
+ ICipherBrowserHealthExclusionRepository cipherBrowserHealthExclusionRepository,
+ IHasPremiumAccessQuery hasPremiumAccessQuery,
+ IPushNotificationService pushService)
+ {
+ _cipherBrowserHealthExclusionRepository = cipherBrowserHealthExclusionRepository;
+ _hasPremiumAccessQuery = hasPremiumAccessQuery;
+ _pushService = pushService;
+ }
+
+ public async Task ExcludeAsync(Guid cipherId, Guid userId)
+ {
+ if (!await _hasPremiumAccessQuery.HasPremiumAccessAsync(userId))
+ {
+ throw new BadRequestException("Premium required to exclude items from browser health.");
+ }
+
+ var excluded = await _cipherBrowserHealthExclusionRepository.ExcludeAsync([cipherId], userId);
+ if (excluded == 0)
+ {
+ throw new NotFoundException();
+ }
+
+ await _pushService.PushSyncCiphersAsync(userId, true);
+ }
+}
diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IExcludeCipherFromBrowserHealthCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IExcludeCipherFromBrowserHealthCommand.cs
new file mode 100644
index 000000000000..f90ac3f1638c
--- /dev/null
+++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IExcludeCipherFromBrowserHealthCommand.cs
@@ -0,0 +1,7 @@
+namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
+
+public interface IExcludeCipherFromBrowserHealthCommand
+{
+ /// Excludes the user's personal login from browser health. Requires premium.
+ Task ExcludeAsync(Guid cipherId, Guid userId);
+}
diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs
index 6aca3160daca..9c133f04789a 100644
--- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs
+++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs
@@ -28,6 +28,7 @@ public static void AddReportingServices(this IServiceCollection services, IGloba
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
// v2 file storage commands
services.AddScoped();
diff --git a/src/Core/Dirt/Repositories/ICipherBrowserHealthExclusionRepository.cs b/src/Core/Dirt/Repositories/ICipherBrowserHealthExclusionRepository.cs
new file mode 100644
index 000000000000..16864beb7e4e
--- /dev/null
+++ b/src/Core/Dirt/Repositories/ICipherBrowserHealthExclusionRepository.cs
@@ -0,0 +1,7 @@
+namespace Bit.Core.Dirt.Repositories;
+
+public interface ICipherBrowserHealthExclusionRepository
+{
+ /// Excludes the user's personal, not-deleted logins from browser health. Returns how many were excluded.
+ Task ExcludeAsync(IEnumerable cipherIds, Guid userId);
+}
diff --git a/src/Core/Vault/Entities/Cipher.cs b/src/Core/Vault/Entities/Cipher.cs
index 08562e5dfdec..59d4df6d5b7f 100644
--- a/src/Core/Vault/Entities/Cipher.cs
+++ b/src/Core/Vault/Entities/Cipher.cs
@@ -27,6 +27,7 @@ public class Cipher : ITableObject, ICloneable
public Enums.CipherRepromptType? Reprompt { get; set; }
public string Key { get; set; }
public string Archives { get; set; }
+ public string BrowserHealthExclusions { get; set; }
public void SetNewId()
{
diff --git a/src/Core/Vault/Models/Data/CipherDetails.cs b/src/Core/Vault/Models/Data/CipherDetails.cs
index 56f5aa39bbab..ffdf09e53186 100644
--- a/src/Core/Vault/Models/Data/CipherDetails.cs
+++ b/src/Core/Vault/Models/Data/CipherDetails.cs
@@ -11,6 +11,8 @@ public class CipherDetails : CipherOrganizationDetails
public bool Manage { get; set; }
// Per-user archived date from Archives JSON.
public DateTime? ArchivedDate { get; set; }
+ // Per-user browser health exclusion flag from BrowserHealthExclusions JSON.
+ public bool ExcludedFromBrowserHealth { get; set; }
public CipherDetails() { }
public CipherDetails(CipherOrganizationDetails cipher)
@@ -53,6 +55,7 @@ public CipherDetailsWithCollections(
Key = cipher.Key;
FolderId = cipher.FolderId;
ArchivedDate = cipher.ArchivedDate;
+ ExcludedFromBrowserHealth = cipher.ExcludedFromBrowserHealth;
Favorite = cipher.Favorite;
Edit = cipher.Edit;
ViewPassword = cipher.ViewPassword;
diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
index 9f9d3c6f5bdc..cd3e5786e2ef 100644
--- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
+++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
@@ -96,6 +96,7 @@ public static void AddDapperRepositories(this IServiceCollection services, bool
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
if (selfHosted)
{
diff --git a/src/Infrastructure.Dapper/Dirt/CipherBrowserHealthExclusionRepository.cs b/src/Infrastructure.Dapper/Dirt/CipherBrowserHealthExclusionRepository.cs
new file mode 100644
index 000000000000..9f596d992a28
--- /dev/null
+++ b/src/Infrastructure.Dapper/Dirt/CipherBrowserHealthExclusionRepository.cs
@@ -0,0 +1,31 @@
+using System.Data;
+using Bit.Core.Dirt.Repositories;
+using Bit.Core.Settings;
+using Bit.Infrastructure.Dapper.Repositories;
+using Dapper;
+using Microsoft.Data.SqlClient;
+
+namespace Bit.Infrastructure.Dapper.Dirt;
+
+public class CipherBrowserHealthExclusionRepository : BaseRepository, ICipherBrowserHealthExclusionRepository
+{
+ public CipherBrowserHealthExclusionRepository(GlobalSettings globalSettings)
+ : base(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
+ {
+ }
+
+ public async Task ExcludeAsync(IEnumerable cipherIds, Guid userId)
+ {
+ var ids = cipherIds.ToList();
+ if (ids.Count == 0)
+ {
+ return 0;
+ }
+
+ await using var connection = new SqlConnection(ConnectionString);
+ return await connection.ExecuteScalarAsync(
+ "[dbo].[Cipher_UpdateManyBrowserHealthExclusions]",
+ new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId, Exclude = true },
+ commandType: CommandType.StoredProcedure);
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/Dirt/CipherBrowserHealthExclusionRepository.cs b/src/Infrastructure.EntityFramework/Dirt/CipherBrowserHealthExclusionRepository.cs
new file mode 100644
index 000000000000..bc3e07ee9100
--- /dev/null
+++ b/src/Infrastructure.EntityFramework/Dirt/CipherBrowserHealthExclusionRepository.cs
@@ -0,0 +1,50 @@
+using AutoMapper;
+using Bit.Core.Dirt.Repositories;
+using Bit.Core.Utilities;
+using Bit.Core.Vault.Enums;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Bit.Infrastructure.EntityFramework.Dirt;
+
+public class CipherBrowserHealthExclusionRepository : BaseEntityFrameworkRepository, ICipherBrowserHealthExclusionRepository
+{
+ public CipherBrowserHealthExclusionRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
+ : base(serviceScopeFactory, mapper)
+ {
+ }
+
+ public async Task ExcludeAsync(IEnumerable cipherIds, Guid userId)
+ {
+ var ids = cipherIds.ToList();
+ if (ids.Count == 0)
+ {
+ return 0;
+ }
+
+ await using var scope = ServiceScopeFactory.CreateAsyncScope();
+ var dbContext = GetDatabaseContext(scope);
+ var ciphers = await dbContext.Ciphers
+ .Where(c => ids.Contains(c.Id) && c.UserId == userId && c.OrganizationId == null
+ && c.Type == CipherType.Login && c.DeletedDate == null)
+ .ToListAsync();
+
+ foreach (var cipher in ciphers)
+ {
+ var exclusions = string.IsNullOrWhiteSpace(cipher.BrowserHealthExclusions)
+ ? new Dictionary()
+ : CoreHelpers.LoadClassFromJsonData>(cipher.BrowserHealthExclusions)
+ ?? new Dictionary();
+ exclusions[userId] = true;
+ cipher.BrowserHealthExclusions = CoreHelpers.ClassToJsonData(exclusions);
+ }
+
+ if (ciphers.Count > 0)
+ {
+ await dbContext.UserBumpAccountRevisionDateAsync(userId);
+ }
+ await dbContext.SaveChangesAsync();
+ return ciphers.Count;
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
index da2dc1dc108c..72f1b1ae823e 100644
--- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
+++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
@@ -132,6 +132,7 @@ public static void AddPasswordManagerEFRepositories(this IServiceCollection serv
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
if (selfHosted)
diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs
index 67be55f1a958..a5fa6b097470 100644
--- a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs
@@ -71,7 +71,8 @@ from cg in cg_g.DefaultIfEmpty()
OrganizationUseTotp = o.UseTotp,
c.Reprompt,
c.Key,
- c.Archives
+ c.Archives,
+ c.BrowserHealthExclusions
};
var query2 = from c in dbContext.Ciphers
@@ -95,7 +96,8 @@ from cg in cg_g.DefaultIfEmpty()
OrganizationUseTotp = false,
c.Reprompt,
c.Key,
- c.Archives
+ c.Archives,
+ c.BrowserHealthExclusions
};
var union = query.Union(query2).Select(c => new CipherDetails
@@ -117,7 +119,8 @@ from cg in cg_g.DefaultIfEmpty()
Manage = c.Manage,
OrganizationUseTotp = c.OrganizationUseTotp,
Key = c.Key,
- ArchivedDate = GetArchivedDate(_userId, new Cipher { Id = c.Id, Archives = c.Archives })
+ ArchivedDate = GetArchivedDate(_userId, new Cipher { Id = c.Id, Archives = c.Archives }),
+ ExcludedFromBrowserHealth = _userId.HasValue && c.BrowserHealthExclusions != null && c.BrowserHealthExclusions.ToLowerInvariant().Contains($"\"{_userId}\":true")
});
return union;
}
@@ -143,6 +146,7 @@ from cg in cg_g.DefaultIfEmpty()
}
}
+
private static Guid? GetFolderId(Guid? userId, Cipher cipher)
{
try
diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs
index 0835790bafaf..3777cd917e50 100644
--- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs
+++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs
@@ -502,6 +502,7 @@ public async Task> GetManyByUserIdAsync(Guid userId,
OrganizationUseTotp = false,
Key = c.Key,
ArchivedDate = c.ArchivedDate,
+ ExcludedFromBrowserHealth = c.ExcludedFromBrowserHealth,
};
}
@@ -561,6 +562,22 @@ await idsToMove.ForEachAsync(cipher =>
}
}
+ public override async Task ReplaceAsync(Core.Vault.Entities.Cipher cipher)
+ {
+ using (var scope = ServiceScopeFactory.CreateScope())
+ {
+ var dbContext = GetDatabaseContext(scope);
+ var entity = await dbContext.Ciphers.FindAsync(cipher.Id);
+ if (entity != null)
+ {
+ var mappedEntity = Mapper.Map(cipher);
+ dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity);
+ dbContext.Entry(entity).Property(c => c.BrowserHealthExclusions).IsModified = false;
+ await dbContext.SaveChangesAsync();
+ }
+ }
+ }
+
public async Task ReplaceAsync(CipherDetails cipher)
{
using (var scope = ServiceScopeFactory.CreateScope())
@@ -634,6 +651,7 @@ public async Task ReplaceAsync(CipherDetails cipher)
var mappedEntity = Mapper.Map(cipher);
dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity);
+ dbContext.Entry(entity).Property(c => c.BrowserHealthExclusions).IsModified = false;
if (cipher.OrganizationId.HasValue)
{
diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs
index 43f368b23227..1d53ac43458b 100644
--- a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs
+++ b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs
@@ -37,6 +37,7 @@ public virtual IQueryable Run(DatabaseContext dbContext)
ArchivedDate = !_userId.HasValue || c.Archives == null || !c.Archives.ToLowerInvariant().Contains(_userId.Value.ToString()) ?
null :
CoreHelpers.LoadClassFromJsonData>(c.Archives)[_userId.Value],
+ ExcludedFromBrowserHealth = _userId.HasValue && c.BrowserHealthExclusions != null && c.BrowserHealthExclusions.ToLowerInvariant().Contains($"\"{_userId}\":true"),
};
return query;
}
diff --git a/src/Sql/dbo/Dirt/Stored Procedures/Cipher_UpdateManyBrowserHealthExclusions.sql b/src/Sql/dbo/Dirt/Stored Procedures/Cipher_UpdateManyBrowserHealthExclusions.sql
new file mode 100644
index 000000000000..f1c82e1dc728
--- /dev/null
+++ b/src/Sql/dbo/Dirt/Stored Procedures/Cipher_UpdateManyBrowserHealthExclusions.sql
@@ -0,0 +1,34 @@
+CREATE PROCEDURE [dbo].[Cipher_UpdateManyBrowserHealthExclusions]
+ @Ids AS [dbo].[GuidIdArray] READONLY,
+ @UserId AS UNIQUEIDENTIFIER,
+ @Exclude AS BIT
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ UPDATE
+ C
+ SET
+ [BrowserHealthExclusions] = JSON_MODIFY(
+ COALESCE(C.[BrowserHealthExclusions], N'{}'),
+ CONCAT('$."', @UserId, '"'),
+ IIF(@Exclude = 1, CAST(1 AS BIT), NULL) -- NULL removes the user's key
+ )
+ FROM
+ [dbo].[Cipher] C
+ INNER JOIN @Ids I ON I.[Id] = C.[Id]
+ WHERE
+ C.[UserId] = @UserId
+ AND C.[OrganizationId] IS NULL
+ AND C.[Type] = 1 -- Login
+ AND C.[DeletedDate] IS NULL
+
+ DECLARE @Count INT = @@ROWCOUNT
+
+ IF @Count > 0
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
+ END
+
+ SELECT @Count
+END
diff --git a/src/Sql/dbo/Vault/Functions/CipherDetails.sql b/src/Sql/dbo/Vault/Functions/CipherDetails.sql
index 51930ec10e6e..a694b21c27f4 100644
--- a/src/Sql/dbo/Vault/Functions/CipherDetails.sql
+++ b/src/Sql/dbo/Vault/Functions/CipherDetails.sql
@@ -34,6 +34,14 @@ SELECT
OR C.[Archives] IS NULL
THEN NULL
ELSE TRY_CONVERT(DATETIME2(7), JSON_VALUE(C.[Archives], CONCAT('$."', @UserId, '"')))
- END [ArchivedDate]
+ END [ArchivedDate],
+ CASE
+ WHEN
+ @UserId IS NULL
+ OR C.[BrowserHealthExclusions] IS NULL
+ OR JSON_VALUE(C.[BrowserHealthExclusions], CONCAT('$."', @UserId, '"')) IS NULL
+ THEN 0
+ ELSE 1
+ END [ExcludedFromBrowserHealth]
FROM
[dbo].[Cipher] C;
diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql
index 846f3bcf8a11..da9378328376 100644
--- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql
+++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql
@@ -19,7 +19,9 @@
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@ArchivedDate DATETIME2(7) = NULL,
- @Archives NVARCHAR(MAX) = NULL -- not used
+ @Archives NVARCHAR(MAX) = NULL, -- not used
+ @ExcludedFromBrowserHealth BIT = NULL, -- not used
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL -- not used
AS
BEGIN
SET NOCOUNT ON
diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql
index da573b1d1395..450d5cd152d4 100644
--- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql
+++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql
@@ -20,6 +20,8 @@
@Key VARCHAR(MAX) = NULL,
@ArchivedDate DATETIME2(7) = NULL,
@Archives NVARCHAR(MAX) = NULL, -- not used
+ @ExcludedFromBrowserHealth BIT = NULL, -- not used
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL, -- not used
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql
index 2646159b623e..3d67c6a97f75 100644
--- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql
+++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql
@@ -21,6 +21,7 @@ SELECT
[Key],
[OrganizationUseTotp],
[ArchivedDate],
+ [ExcludedFromBrowserHealth],
MAX ([Edit]) AS [Edit],
MAX ([ViewPassword]) AS [ViewPassword],
MAX ([Manage]) AS [Manage]
@@ -43,5 +44,6 @@ SELECT
[Reprompt],
[Key],
[OrganizationUseTotp],
- [ArchivedDate]
+ [ArchivedDate],
+ [ExcludedFromBrowserHealth]
END
diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql
index 4676f20fd38b..52ca63a90800 100644
--- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql
+++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql
@@ -19,7 +19,9 @@
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@ArchivedDate DATETIME2(7) = NULL,
- @Archives NVARCHAR(MAX) = NULL -- not used
+ @Archives NVARCHAR(MAX) = NULL, -- not used
+ @ExcludedFromBrowserHealth BIT = NULL, -- not used
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL -- not used
AS
BEGIN
SET NOCOUNT ON
diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql
index 68e7b11c0710..7d7c85b4babc 100644
--- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql
+++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql
@@ -12,7 +12,8 @@
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
- @Archives NVARCHAR(MAX) = NULL
+ @Archives NVARCHAR(MAX) = NULL,
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL -- not used
AS
BEGIN
SET NOCOUNT ON
diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql
index 071bf82f87cd..79289d1afc32 100644
--- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql
+++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql
@@ -13,6 +13,7 @@
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@Archives NVARCHAR(MAX) = NULL,
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL, -- not used
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql
index e92a8147eac1..90ace7ac0951 100644
--- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql
+++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql
@@ -12,7 +12,8 @@
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
- @Archives NVARCHAR(MAX) = NULL
+ @Archives NVARCHAR(MAX) = NULL,
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL -- not used
AS
BEGIN
SET NOCOUNT ON
diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql
index c33d49e92ec2..e710796e13c9 100644
--- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql
+++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql
@@ -13,6 +13,7 @@
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@Archives NVARCHAR(MAX) = NULL,
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL, -- not used
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
diff --git a/src/Sql/dbo/Vault/Tables/Cipher.sql b/src/Sql/dbo/Vault/Tables/Cipher.sql
index 75ebc1d61803..37d42838379e 100644
--- a/src/Sql/dbo/Vault/Tables/Cipher.sql
+++ b/src/Sql/dbo/Vault/Tables/Cipher.sql
@@ -1,20 +1,21 @@
CREATE TABLE [dbo].[Cipher] (
- [Id] UNIQUEIDENTIFIER NOT NULL,
- [UserId] UNIQUEIDENTIFIER NULL,
- [OrganizationId] UNIQUEIDENTIFIER NULL,
- [Type] TINYINT NOT NULL,
- [Data] NVARCHAR (MAX) NOT NULL,
- [Favorites] NVARCHAR (MAX) NULL,
- [Folders] NVARCHAR (MAX) NULL,
- [Attachments] NVARCHAR (MAX) NULL,
- [CreationDate] DATETIME2 (7) NOT NULL,
- [RevisionDate] DATETIME2 (7) NOT NULL,
- [DeletedDate] DATETIME2 (7) NULL,
- [Reprompt] TINYINT NULL,
- [Key] VARCHAR(MAX) NULL,
- [ArchivedDate] DATETIME2 (7) NULL,
- [Archives] NVARCHAR(MAX) NULL,
+ [Id] UNIQUEIDENTIFIER NOT NULL,
+ [UserId] UNIQUEIDENTIFIER NULL,
+ [OrganizationId] UNIQUEIDENTIFIER NULL,
+ [Type] TINYINT NOT NULL,
+ [Data] NVARCHAR (MAX) NOT NULL,
+ [Favorites] NVARCHAR (MAX) NULL,
+ [Folders] NVARCHAR (MAX) NULL,
+ [Attachments] NVARCHAR (MAX) NULL,
+ [CreationDate] DATETIME2 (7) NOT NULL,
+ [RevisionDate] DATETIME2 (7) NOT NULL,
+ [DeletedDate] DATETIME2 (7) NULL,
+ [Reprompt] TINYINT NULL,
+ [Key] VARCHAR(MAX) NULL,
+ [ArchivedDate] DATETIME2 (7) NULL,
+ [Archives] NVARCHAR(MAX) NULL,
+ [BrowserHealthExclusions] NVARCHAR(MAX) NULL,
CONSTRAINT [PK_Cipher] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_Cipher_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
CONSTRAINT [FK_Cipher_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id])
diff --git a/test/Api.Test/Dirt/CipherBrowserHealthExclusionsControllerTests.cs b/test/Api.Test/Dirt/CipherBrowserHealthExclusionsControllerTests.cs
new file mode 100644
index 000000000000..abdf92441b43
--- /dev/null
+++ b/test/Api.Test/Dirt/CipherBrowserHealthExclusionsControllerTests.cs
@@ -0,0 +1,26 @@
+using System.Security.Claims;
+using Bit.Api.Dirt.Controllers;
+using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
+using Bit.Core.Services;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Api.Test.Dirt;
+
+[ControllerCustomize(typeof(CipherBrowserHealthExclusionsController))]
+[SutProviderCustomize]
+public class CipherBrowserHealthExclusionsControllerTests
+{
+ [Theory, BitAutoData]
+ public async Task PutExcludeFromBrowserHealth_CallsCommandWithCallingUser(
+ SutProvider sutProvider, Guid cipherId, Guid userId)
+ {
+ sutProvider.GetDependency().GetProperUserId(Arg.Any()).Returns(userId);
+
+ await sutProvider.Sut.PutExcludeFromBrowserHealthAsync(cipherId);
+
+ await sutProvider.GetDependency().Received(1).ExcludeAsync(cipherId, userId);
+ }
+}
diff --git a/test/Core.Test/Dirt/ReportFeatures/ExcludeCipherFromBrowserHealthCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/ExcludeCipherFromBrowserHealthCommandTests.cs
new file mode 100644
index 000000000000..e2e8cf904fca
--- /dev/null
+++ b/test/Core.Test/Dirt/ReportFeatures/ExcludeCipherFromBrowserHealthCommandTests.cs
@@ -0,0 +1,60 @@
+using Bit.Core.Billing.Premium.Queries;
+using Bit.Core.Dirt.Reports.ReportFeatures;
+using Bit.Core.Dirt.Repositories;
+using Bit.Core.Enums;
+using Bit.Core.Exceptions;
+using Bit.Core.Models;
+using Bit.Core.Platform.Push;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.Dirt.ReportFeatures;
+
+[SutProviderCustomize]
+public class ExcludeCipherFromBrowserHealthCommandTests
+{
+ [Theory, BitAutoData]
+ public async Task ExcludeAsync_WithoutPremium_ThrowsBadRequest_AndDoesNotWrite(
+ SutProvider sutProvider, Guid cipherId, Guid userId)
+ {
+ sutProvider.GetDependency().HasPremiumAccessAsync(userId).Returns(false);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.ExcludeAsync(cipherId, userId));
+
+ await sutProvider.GetDependency()
+ .DidNotReceiveWithAnyArgs().ExcludeAsync(default!, default);
+ await sutProvider.GetDependency()
+ .DidNotReceiveWithAnyArgs().PushAsync(Arg.Any>());
+ }
+
+ [Theory, BitAutoData]
+ public async Task ExcludeAsync_NothingExcluded_ThrowsNotFound_AndDoesNotPush(
+ SutProvider sutProvider, Guid cipherId, Guid userId)
+ {
+ sutProvider.GetDependency().HasPremiumAccessAsync(userId).Returns(true);
+ sutProvider.GetDependency()
+ .ExcludeAsync(Arg.Any>(), userId).Returns(0);
+
+ await Assert.ThrowsAsync(() => sutProvider.Sut.ExcludeAsync(cipherId, userId));
+
+ await sutProvider.GetDependency()
+ .DidNotReceiveWithAnyArgs().PushAsync(Arg.Any>());
+ }
+
+ [Theory, BitAutoData]
+ public async Task ExcludeAsync_Excluded_PushesCipherSyncExcludingCurrentContext(
+ SutProvider sutProvider, Guid cipherId, Guid userId)
+ {
+ sutProvider.GetDependency().HasPremiumAccessAsync(userId).Returns(true);
+ sutProvider.GetDependency()
+ .ExcludeAsync(Arg.Is>(ids => ids.Single() == cipherId), userId).Returns(1);
+
+ await sutProvider.Sut.ExcludeAsync(cipherId, userId);
+
+ await sutProvider.GetDependency().Received(1)
+ .PushAsync(Arg.Is>(n =>
+ n.Type == PushType.SyncCiphers && n.TargetId == userId && n.ExcludeCurrentContext));
+ }
+}
diff --git a/test/Infrastructure.IntegrationTest/Dirt/Repositories/CipherBrowserHealthExclusionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Dirt/Repositories/CipherBrowserHealthExclusionRepositoryTests.cs
new file mode 100644
index 000000000000..8015856ddf32
--- /dev/null
+++ b/test/Infrastructure.IntegrationTest/Dirt/Repositories/CipherBrowserHealthExclusionRepositoryTests.cs
@@ -0,0 +1,218 @@
+using Bit.Core.Dirt.Repositories;
+using Bit.Core.Entities;
+using Bit.Core.Models.Data;
+using Bit.Core.Repositories;
+using Bit.Core.Vault.Entities;
+using Bit.Core.Vault.Enums;
+using Bit.Core.Vault.Repositories;
+using Bit.Infrastructure.IntegrationTest.AdminConsole;
+using Xunit;
+
+namespace Bit.Infrastructure.IntegrationTest.Dirt.Repositories;
+
+public class CipherBrowserHealthExclusionRepositoryTests
+{
+ [DatabaseTheory, DatabaseData]
+ public async Task ExcludeAsync_PersonalLogin_ReturnsOneAndSetsUsersExcludedFromBrowserHealth(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var cipher = await CreateCipherAsync(cipherRepository, user.Id);
+
+ var excluded = await sut.ExcludeAsync([cipher.Id], user.Id);
+
+ Assert.Equal(1, excluded);
+ var details = await cipherRepository.GetByIdAsync(cipher.Id, user.Id);
+ Assert.True(details.ExcludedFromBrowserHealth);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task ExcludeAsync_OrganizationCipher_ReturnsZero(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository,
+ IOrganizationRepository organizationRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var org = await organizationRepository.CreateTestOrganizationAsync();
+ var cipher = await cipherRepository.CreateAsync(new Cipher
+ {
+ Type = CipherType.Login,
+ UserId = user.Id,
+ OrganizationId = org.Id,
+ Data = "",
+ });
+
+ Assert.Equal(0, await sut.ExcludeAsync([cipher.Id], user.Id));
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task ExcludeAsync_NonLoginCipher_ReturnsZero(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var cipher = await CreateCipherAsync(cipherRepository, user.Id, CipherType.SecureNote);
+
+ Assert.Equal(0, await sut.ExcludeAsync([cipher.Id], user.Id));
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task ExcludeAsync_DeletedCipher_ReturnsZero(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var cipher = await CreateCipherAsync(cipherRepository, user.Id);
+ await cipherRepository.SoftDeleteAsync([cipher.Id], user.Id);
+
+ Assert.Equal(0, await sut.ExcludeAsync([cipher.Id], user.Id));
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task ExcludeAsync_OtherUsersCipher_ReturnsZero(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository)
+ {
+ var owner = await userRepository.CreateTestUserAsync();
+ var otherUser = await userRepository.CreateTestUserAsync();
+ var cipher = await CreateCipherAsync(cipherRepository, owner.Id);
+
+ Assert.Equal(0, await sut.ExcludeAsync([cipher.Id], otherUser.Id));
+ Assert.False((await cipherRepository.GetByIdAsync(cipher.Id, owner.Id)).ExcludedFromBrowserHealth);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task ExcludeAsync_AlreadyExcluded_ReturnsOne(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var cipher = await CreateCipherAsync(cipherRepository, user.Id);
+ await sut.ExcludeAsync([cipher.Id], user.Id);
+
+ Assert.Equal(1, await sut.ExcludeAsync([cipher.Id], user.Id));
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task ExcludeAsync_DoesNotChangeCipherRevisionDate(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var cipher = await CreateCipherAsync(cipherRepository, user.Id);
+ var before = (await cipherRepository.GetByIdAsync(cipher.Id, user.Id)).RevisionDate;
+
+ await sut.ExcludeAsync([cipher.Id], user.Id);
+
+ Assert.Equal(before, (await cipherRepository.GetByIdAsync(cipher.Id, user.Id)).RevisionDate);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task ExcludeAsync_BumpsAccountRevisionDate(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var cipher = await CreateCipherAsync(cipherRepository, user.Id);
+ var before = (await userRepository.GetByIdAsync(user.Id))!.AccountRevisionDate;
+
+ await sut.ExcludeAsync([cipher.Id], user.Id);
+
+ Assert.True((await userRepository.GetByIdAsync(user.Id))!.AccountRevisionDate > before);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetManyByUserIdAsync_ReturnsExcludedFromBrowserHealth(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var excludedCipher = await CreateCipherAsync(cipherRepository, user.Id);
+ var otherCipher = await CreateCipherAsync(cipherRepository, user.Id);
+
+ await sut.ExcludeAsync([excludedCipher.Id], user.Id);
+
+ var ciphers = await cipherRepository.GetManyByUserIdAsync(user.Id);
+ Assert.True(ciphers.Single(c => c.Id == excludedCipher.Id).ExcludedFromBrowserHealth);
+ Assert.False(ciphers.Single(c => c.Id == otherCipher.Id).ExcludedFromBrowserHealth);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetManyByUserIdAsync_WithoutOrganizations_ReturnsExcludedFromBrowserHealth(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var cipher = await CreateCipherAsync(cipherRepository, user.Id);
+
+ await sut.ExcludeAsync([cipher.Id], user.Id);
+
+ var ciphers = await cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: false);
+ Assert.True(ciphers.Single(c => c.Id == cipher.Id).ExcludedFromBrowserHealth);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task ReplaceAsync_CipherDetails_PreservesBrowserHealthExclusions(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var cipher = await CreateCipherAsync(cipherRepository, user.Id);
+ await sut.ExcludeAsync([cipher.Id], user.Id);
+
+ var details = await cipherRepository.GetByIdAsync(cipher.Id, user.Id);
+ details.Data = "{\"updated\":true}";
+ await cipherRepository.ReplaceAsync(details);
+
+ Assert.True((await cipherRepository.GetByIdAsync(cipher.Id, user.Id)).ExcludedFromBrowserHealth);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task ReplaceAsync_Cipher_PreservesBrowserHealthExclusions(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var cipher = await CreateCipherAsync(cipherRepository, user.Id);
+ await sut.ExcludeAsync([cipher.Id], user.Id);
+
+ cipher.Data = "{\"updated\":true}";
+ await cipherRepository.ReplaceAsync(cipher);
+
+ Assert.True((await cipherRepository.GetByIdAsync(cipher.Id, user.Id)).ExcludedFromBrowserHealth);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task SoftDeleteAsync_KeepsBrowserHealthExclusion(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository)
+ {
+ var user = await userRepository.CreateTestUserAsync();
+ var cipher = await CreateCipherAsync(cipherRepository, user.Id);
+ await sut.ExcludeAsync([cipher.Id], user.Id);
+
+ await cipherRepository.SoftDeleteAsync([cipher.Id], user.Id);
+
+ Assert.True((await cipherRepository.GetByIdAsync(cipher.Id, user.Id)).ExcludedFromBrowserHealth);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task ReplaceAsync_ShareToOrg_KeepsOwnersExclusionOnly(
+ ICipherBrowserHealthExclusionRepository sut, ICipherRepository cipherRepository, IUserRepository userRepository,
+ IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository,
+ ICollectionRepository collectionRepository)
+ {
+ var owner = await userRepository.CreateTestUserAsync();
+ var member = await userRepository.CreateTestUserAsync();
+ var org = await organizationRepository.CreateTestOrganizationAsync();
+ var ownerOrgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(org, owner);
+ var memberOrgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(org, member);
+ var collection = await collectionRepository.CreateAsync(new Collection { Name = "Shared", OrganizationId = org.Id });
+ await collectionRepository.UpdateUsersAsync(collection.Id, new List
+ {
+ new() { Id = ownerOrgUser.Id, Manage = true },
+ new() { Id = memberOrgUser.Id, Manage = true },
+ });
+ var cipher = await CreateCipherAsync(cipherRepository, owner.Id);
+ await sut.ExcludeAsync([cipher.Id], owner.Id);
+
+ cipher.OrganizationId = org.Id;
+ await cipherRepository.ReplaceAsync(cipher, [collection.Id]);
+
+ Assert.True((await cipherRepository.GetByIdAsync(cipher.Id, owner.Id)).ExcludedFromBrowserHealth);
+ Assert.False((await cipherRepository.GetManyByUserIdAsync(member.Id)).Single(c => c.Id == cipher.Id).ExcludedFromBrowserHealth);
+ }
+ private static Task CreateCipherAsync(ICipherRepository cipherRepository, Guid userId,
+ CipherType type = CipherType.Login) =>
+ cipherRepository.CreateAsync(new Cipher { Type = type, UserId = userId, Data = "" });
+
+
+}
diff --git a/util/Migrator/DbScripts/2026-10-27_00_AddCipherBrowserHealthExclusions.sql b/util/Migrator/DbScripts/2026-10-27_00_AddCipherBrowserHealthExclusions.sql
new file mode 100644
index 000000000000..6f43676697ba
--- /dev/null
+++ b/util/Migrator/DbScripts/2026-10-27_00_AddCipherBrowserHealthExclusions.sql
@@ -0,0 +1,569 @@
+IF COL_LENGTH('[dbo].[Cipher]', 'BrowserHealthExclusions') IS NULL
+BEGIN
+ ALTER TABLE [dbo].[Cipher]
+ ADD [BrowserHealthExclusions] NVARCHAR(MAX) NULL;
+END
+GO
+
+CREATE OR ALTER FUNCTION [dbo].[CipherDetails](@UserId UNIQUEIDENTIFIER)
+RETURNS TABLE
+AS RETURN
+SELECT
+ C.[Id],
+ C.[UserId],
+ C.[OrganizationId],
+ C.[Type],
+ C.[Data],
+ C.[Attachments],
+ C.[CreationDate],
+ C.[RevisionDate],
+ CASE
+ WHEN
+ @UserId IS NULL
+ OR C.[Favorites] IS NULL
+ OR JSON_VALUE(C.[Favorites], CONCAT('$."', @UserId, '"')) IS NULL
+ THEN 0
+ ELSE 1
+ END [Favorite],
+ CASE
+ WHEN
+ @UserId IS NULL
+ OR C.[Folders] IS NULL
+ THEN NULL
+ ELSE TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(C.[Folders], CONCAT('$."', @UserId, '"')))
+ END [FolderId],
+ C.[DeletedDate],
+ C.[Reprompt],
+ C.[Key],
+ CASE
+ WHEN
+ @UserId IS NULL
+ OR C.[Archives] IS NULL
+ THEN NULL
+ ELSE TRY_CONVERT(DATETIME2(7), JSON_VALUE(C.[Archives], CONCAT('$."', @UserId, '"')))
+ END [ArchivedDate],
+ CASE
+ WHEN
+ @UserId IS NULL
+ OR C.[BrowserHealthExclusions] IS NULL
+ OR JSON_VALUE(C.[BrowserHealthExclusions], CONCAT('$."', @UserId, '"')) IS NULL
+ THEN 0
+ ELSE 1
+ END [ExcludedFromBrowserHealth]
+FROM
+ [dbo].[Cipher] C;
+GO
+
+IF OBJECT_ID('[dbo].[UserCipherDetails]') IS NOT NULL
+BEGIN
+ EXECUTE sp_refreshsqlmodule N'[dbo].[UserCipherDetails]';
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[Cipher_Create]
+ @Id UNIQUEIDENTIFIER OUTPUT,
+ @UserId UNIQUEIDENTIFIER,
+ @OrganizationId UNIQUEIDENTIFIER,
+ @Type TINYINT,
+ @Data NVARCHAR(MAX),
+ @Favorites NVARCHAR(MAX),
+ @Folders NVARCHAR(MAX),
+ @Attachments NVARCHAR(MAX), -- not used
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @DeletedDate DATETIME2(7),
+ @Reprompt TINYINT,
+ @Key VARCHAR(MAX) = NULL,
+ @Archives NVARCHAR(MAX) = NULL,
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL -- not used
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ INSERT INTO [dbo].[Cipher]
+ (
+ [Id],
+ [UserId],
+ [OrganizationId],
+ [Type],
+ [Data],
+ [Favorites],
+ [Folders],
+ [CreationDate],
+ [RevisionDate],
+ [DeletedDate],
+ [Reprompt],
+ [Key],
+ [Archives]
+ )
+ VALUES
+ (
+ @Id,
+ CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
+ @OrganizationId,
+ @Type,
+ @Data,
+ @Favorites,
+ @Folders,
+ @CreationDate,
+ @RevisionDate,
+ @DeletedDate,
+ @Reprompt,
+ @Key,
+ @Archives
+ )
+
+ IF @OrganizationId IS NOT NULL
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
+ END
+ ELSE IF @UserId IS NOT NULL
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
+ END
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[Cipher_Update]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @OrganizationId UNIQUEIDENTIFIER,
+ @Type TINYINT,
+ @Data NVARCHAR(MAX),
+ @Favorites NVARCHAR(MAX),
+ @Folders NVARCHAR(MAX),
+ @Attachments NVARCHAR(MAX),
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @DeletedDate DATETIME2(7),
+ @Reprompt TINYINT,
+ @Key VARCHAR(MAX) = NULL,
+ @Archives NVARCHAR(MAX) = NULL,
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL -- not used
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ UPDATE
+ [dbo].[Cipher]
+ SET
+ [UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
+ [OrganizationId] = @OrganizationId,
+ [Type] = @Type,
+ [Data] = @Data,
+ [Favorites] = @Favorites,
+ [Folders] = @Folders,
+ [Attachments] = @Attachments,
+ [CreationDate] = @CreationDate,
+ [RevisionDate] = @RevisionDate,
+ [DeletedDate] = @DeletedDate,
+ [Reprompt] = @Reprompt,
+ [Key] = @Key,
+ [Archives] = @Archives
+ WHERE
+ [Id] = @Id
+
+ IF @OrganizationId IS NOT NULL
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
+ END
+ ELSE IF @UserId IS NOT NULL
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
+ END
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[Cipher_CreateWithCollections]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @OrganizationId UNIQUEIDENTIFIER,
+ @Type TINYINT,
+ @Data NVARCHAR(MAX),
+ @Favorites NVARCHAR(MAX),
+ @Folders NVARCHAR(MAX),
+ @Attachments NVARCHAR(MAX),
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @DeletedDate DATETIME2(7),
+ @Reprompt TINYINT,
+ @Key VARCHAR(MAX) = NULL,
+ @Archives NVARCHAR(MAX) = NULL,
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL, -- not used
+ @CollectionIds AS [dbo].[GuidIdArray] READONLY
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ EXEC [dbo].[Cipher_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders,
+ @Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @Archives
+
+ DECLARE @UpdateCollectionsSuccess INT
+ EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds
+
+ -- Bump the account revision date AFTER collections are assigned.
+ IF @UpdateCollectionsSuccess = 0
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
+ END
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[Cipher_UpdateWithCollections]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @OrganizationId UNIQUEIDENTIFIER,
+ @Type TINYINT,
+ @Data NVARCHAR(MAX),
+ @Favorites NVARCHAR(MAX),
+ @Folders NVARCHAR(MAX),
+ @Attachments NVARCHAR(MAX),
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @DeletedDate DATETIME2(7),
+ @Reprompt TINYINT,
+ @Key VARCHAR(MAX) = NULL,
+ @Archives NVARCHAR(MAX) = NULL,
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL, -- not used
+ @CollectionIds AS [dbo].[GuidIdArray] READONLY
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ BEGIN TRANSACTION Cipher_UpdateWithCollections
+
+ DECLARE @UpdateCollectionsSuccess INT
+ EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds
+
+ IF @UpdateCollectionsSuccess < 0
+ BEGIN
+ COMMIT TRANSACTION Cipher_UpdateWithCollections
+ SELECT -1 -- -1 = Failure
+ RETURN
+ END
+
+ UPDATE
+ [dbo].[Cipher]
+ SET
+ [UserId] = NULL,
+ [OrganizationId] = @OrganizationId,
+ [Data] = @Data,
+ [Attachments] = @Attachments,
+ [RevisionDate] = @RevisionDate,
+ [DeletedDate] = @DeletedDate,
+ [Key] = @Key,
+ [Folders] = @Folders,
+ [Favorites] = @Favorites,
+ [Reprompt] = @Reprompt,
+ [Archives] = @Archives
+ -- No need to update CreationDate or Type since that data will not change
+ WHERE
+ [Id] = @Id
+
+ COMMIT TRANSACTION Cipher_UpdateWithCollections
+
+ IF @Attachments IS NOT NULL
+ BEGIN
+ EXEC [dbo].[Organization_UpdateStorage] @OrganizationId
+ EXEC [dbo].[User_UpdateStorage] @UserId
+ END
+
+ EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
+
+ SELECT 0 -- 0 = Success
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_Create]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @OrganizationId UNIQUEIDENTIFIER,
+ @Type TINYINT,
+ @Data NVARCHAR(MAX),
+ @Favorites NVARCHAR(MAX), -- not used
+ @Folders NVARCHAR(MAX), -- not used
+ @Attachments NVARCHAR(MAX), -- not used
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @FolderId UNIQUEIDENTIFIER,
+ @Favorite BIT,
+ @Edit BIT, -- not used
+ @ViewPassword BIT, -- not used
+ @Manage BIT, -- not used
+ @OrganizationUseTotp BIT, -- not used
+ @DeletedDate DATETIME2(7),
+ @Reprompt TINYINT,
+ @Key VARCHAR(MAX) = NULL,
+ @ArchivedDate DATETIME2(7) = NULL,
+ @Archives NVARCHAR(MAX) = NULL, -- not used
+ @ExcludedFromBrowserHealth BIT = NULL, -- not used
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL -- not used
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"')
+ DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey)
+
+ INSERT INTO [dbo].[Cipher]
+ (
+ [Id],
+ [UserId],
+ [OrganizationId],
+ [Type],
+ [Data],
+ [Favorites],
+ [Folders],
+ [CreationDate],
+ [RevisionDate],
+ [DeletedDate],
+ [Reprompt],
+ [Key],
+ [Archives]
+ )
+ VALUES
+ (
+ @Id,
+ CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
+ @OrganizationId,
+ @Type,
+ @Data,
+ CASE WHEN @Favorite = 1 THEN CONCAT('{', @UserIdKey, ':true}') ELSE NULL END,
+ CASE WHEN @FolderId IS NOT NULL THEN CONCAT('{', @UserIdKey, ':"', @FolderId, '"', '}') ELSE NULL END,
+ @CreationDate,
+ @RevisionDate,
+ @DeletedDate,
+ @Reprompt,
+ @Key,
+ CASE WHEN @ArchivedDate IS NOT NULL THEN CONCAT('{', @UserIdKey, ':"', CONVERT(NVARCHAR(30), @ArchivedDate, 127), '"}') ELSE NULL END
+ )
+
+ IF @OrganizationId IS NOT NULL
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
+ END
+ ELSE IF @UserId IS NOT NULL
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
+ END
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_CreateWithCollections]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @OrganizationId UNIQUEIDENTIFIER,
+ @Type TINYINT,
+ @Data NVARCHAR(MAX),
+ @Favorites NVARCHAR(MAX), -- not used
+ @Folders NVARCHAR(MAX), -- not used
+ @Attachments NVARCHAR(MAX), -- not used
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @FolderId UNIQUEIDENTIFIER,
+ @Favorite BIT,
+ @Edit BIT, -- not used
+ @ViewPassword BIT, -- not used
+ @Manage BIT, -- not used
+ @OrganizationUseTotp BIT, -- not used
+ @DeletedDate DATETIME2(7),
+ @Reprompt TINYINT,
+ @Key VARCHAR(MAX) = NULL,
+ @ArchivedDate DATETIME2(7) = NULL,
+ @Archives NVARCHAR(MAX) = NULL, -- not used
+ @ExcludedFromBrowserHealth BIT = NULL, -- not used
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL, -- not used
+ @CollectionIds AS [dbo].[GuidIdArray] READONLY
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ EXEC [dbo].[CipherDetails_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders,
+ @Attachments, @CreationDate, @RevisionDate, @FolderId, @Favorite, @Edit, @ViewPassword, @Manage,
+ @OrganizationUseTotp, @DeletedDate, @Reprompt, @Key, @ArchivedDate
+
+ DECLARE @UpdateCollectionsSuccess INT
+ EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds
+
+ -- Bump the account revision date AFTER collections are assigned.
+ IF @UpdateCollectionsSuccess = 0
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
+ END
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_Update]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @OrganizationId UNIQUEIDENTIFIER,
+ @Type TINYINT,
+ @Data NVARCHAR(MAX),
+ @Favorites NVARCHAR(MAX), -- not used
+ @Folders NVARCHAR(MAX), -- not used
+ @Attachments NVARCHAR(MAX),
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @FolderId UNIQUEIDENTIFIER,
+ @Favorite BIT,
+ @Edit BIT, -- not used
+ @ViewPassword BIT, -- not used
+ @Manage BIT, -- not used
+ @OrganizationUseTotp BIT, -- not used
+ @DeletedDate DATETIME2(2),
+ @Reprompt TINYINT,
+ @Key VARCHAR(MAX) = NULL,
+ @ArchivedDate DATETIME2(7) = NULL,
+ @Archives NVARCHAR(MAX) = NULL, -- not used
+ @ExcludedFromBrowserHealth BIT = NULL, -- not used
+ @BrowserHealthExclusions NVARCHAR(MAX) = NULL -- not used
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"')
+ DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey)
+
+ UPDATE
+ [dbo].[Cipher]
+ SET
+ [UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
+ [OrganizationId] = @OrganizationId,
+ [Type] = @Type,
+ [Data] = @Data,
+ [Folders] =
+ CASE
+ WHEN @FolderId IS NOT NULL AND [Folders] IS NULL THEN
+ CONCAT('{', @UserIdKey, ':"', @FolderId, '"', '}')
+ WHEN @FolderId IS NOT NULL THEN
+ JSON_MODIFY([Folders], @UserIdPath, CAST(@FolderId AS VARCHAR(50)))
+ ELSE
+ JSON_MODIFY([Folders], @UserIdPath, NULL)
+ END,
+ [Favorites] =
+ CASE
+ WHEN @Favorite = 1 AND [Favorites] IS NULL THEN
+ CONCAT('{', @UserIdKey, ':true}')
+ WHEN @Favorite = 1 THEN
+ JSON_MODIFY([Favorites], @UserIdPath, CAST(1 AS BIT))
+ ELSE
+ JSON_MODIFY([Favorites], @UserIdPath, NULL)
+ END,
+ [Archives] =
+ CASE
+ WHEN @ArchivedDate IS NOT NULL AND [Archives] IS NULL THEN
+ CONCAT('{', @UserIdKey, ':"', CONVERT(NVARCHAR(30), @ArchivedDate, 127), '"}')
+ WHEN @ArchivedDate IS NOT NULL THEN
+ JSON_MODIFY([Archives], @UserIdPath, CONVERT(NVARCHAR(30), @ArchivedDate, 127))
+ ELSE
+ JSON_MODIFY([Archives], @UserIdPath, NULL)
+ END,
+ [Attachments] = @Attachments,
+ [Reprompt] = @Reprompt,
+ [CreationDate] = @CreationDate,
+ [RevisionDate] = @RevisionDate,
+ [DeletedDate] = @DeletedDate,
+ [Key] = @Key
+ WHERE
+ [Id] = @Id
+
+ IF @OrganizationId IS NOT NULL
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
+ END
+ ELSE IF @UserId IS NOT NULL
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
+ END
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_ReadByIdUserId]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+SELECT
+ [Id],
+ [UserId],
+ [OrganizationId],
+ [Type],
+ [Data],
+ [Attachments],
+ [CreationDate],
+ [RevisionDate],
+ [Favorite],
+ [FolderId],
+ [DeletedDate],
+ [Reprompt],
+ [Key],
+ [OrganizationUseTotp],
+ [ArchivedDate],
+ [ExcludedFromBrowserHealth],
+ MAX ([Edit]) AS [Edit],
+ MAX ([ViewPassword]) AS [ViewPassword],
+ MAX ([Manage]) AS [Manage]
+ FROM
+ [dbo].[UserCipherDetails](@UserId)
+ WHERE
+ [Id] = @Id
+ GROUP BY
+ [Id],
+ [UserId],
+ [OrganizationId],
+ [Type],
+ [Data],
+ [Attachments],
+ [CreationDate],
+ [RevisionDate],
+ [Favorite],
+ [FolderId],
+ [DeletedDate],
+ [Reprompt],
+ [Key],
+ [OrganizationUseTotp],
+ [ArchivedDate],
+ [ExcludedFromBrowserHealth]
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[Cipher_UpdateManyBrowserHealthExclusions]
+ @Ids AS [dbo].[GuidIdArray] READONLY,
+ @UserId AS UNIQUEIDENTIFIER,
+ @Exclude AS BIT
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ UPDATE
+ C
+ SET
+ [BrowserHealthExclusions] = JSON_MODIFY(
+ COALESCE(C.[BrowserHealthExclusions], N'{}'),
+ CONCAT('$."', @UserId, '"'),
+ IIF(@Exclude = 1, CAST(1 AS BIT), NULL) -- NULL removes the user's key
+ )
+ FROM
+ [dbo].[Cipher] C
+ INNER JOIN @Ids I ON I.[Id] = C.[Id]
+ WHERE
+ C.[UserId] = @UserId
+ AND C.[OrganizationId] IS NULL
+ AND C.[Type] = 1 -- Login
+ AND C.[DeletedDate] IS NULL
+
+ DECLARE @Count INT = @@ROWCOUNT
+
+ IF @Count > 0
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
+ END
+
+ SELECT @Count
+END
+GO
+
+EXECUTE sp_refreshview N'[dbo].[CipherView]'
+GO
diff --git a/util/MySqlMigrations/Migrations/20261026215410_AddCipherBrowserHealthExclusions.Designer.cs b/util/MySqlMigrations/Migrations/20261026215410_AddCipherBrowserHealthExclusions.Designer.cs
new file mode 100644
index 000000000000..9f7c385f5654
--- /dev/null
+++ b/util/MySqlMigrations/Migrations/20261026215410_AddCipherBrowserHealthExclusions.Designer.cs
@@ -0,0 +1,4708 @@
+//
+using System;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Bit.MySqlMigrations.Migrations
+{
+ [DbContext(typeof(DatabaseContext))]
+ [Migration("20261026215410_AddCipherBrowserHealthExclusions")]
+ partial class AddCipherBrowserHealthExclusions
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b =>
+ {
+ b.Property("CipherId")
+ .HasColumnType("char(36)");
+
+ b.Property("CollectionId")
+ .HasColumnType("char(36)");
+
+ b.Property("CollectionName")
+ .HasColumnType("longtext");
+
+ b.Property("Email")
+ .HasColumnType("longtext");
+
+ b.Property("GroupId")
+ .HasColumnType("char(36)");
+
+ b.Property("GroupName")
+ .HasColumnType("longtext");
+
+ b.Property("HidePasswords")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Manage")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ReadOnly")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ResetPasswordKey")
+ .HasColumnType("longtext");
+
+ b.Property("TwoFactorProviders")
+ .HasColumnType("longtext");
+
+ b.Property("UserGuid")
+ .HasColumnType("char(36)");
+
+ b.Property("UserName")
+ .HasColumnType("longtext");
+
+ b.Property("UsesKeyConnector")
+ .HasColumnType("tinyint(1)");
+
+ b.ToTable("OrganizationMemberBaseDetails");
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Collection", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AccessRuleId")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DefaultUserCollectionEmail")
+ .HasColumnType("longtext");
+
+ b.Property("ExternalId")
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessRuleId");
+
+ b.HasIndex("OrganizationId");
+
+ b.ToTable("Collection", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionGroup", b =>
+ {
+ b.Property("CollectionId")
+ .HasColumnType("char(36)");
+
+ b.Property("GroupId")
+ .HasColumnType("char(36)");
+
+ b.Property("HidePasswords")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Manage")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ReadOnly")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("CollectionId", "GroupId");
+
+ b.HasIndex("GroupId");
+
+ b.ToTable("CollectionGroups");
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.CollectionUser", b =>
+ {
+ b.Property("CollectionId")
+ .HasColumnType("char(36)");
+
+ b.Property("OrganizationUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("HidePasswords")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Manage")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ReadOnly")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("CollectionId", "OrganizationUserId");
+
+ b.HasIndex("OrganizationUserId");
+
+ b.ToTable("CollectionUsers");
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AllowAdminAccessToAllCollectionItems")
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("BillingEmail")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("BusinessAddress1")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessAddress2")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessAddress3")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessCountry")
+ .HasMaxLength(2)
+ .HasColumnType("varchar(2)");
+
+ b.Property("BusinessName")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessTaxNumber")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ExemptFromBillingAutomation")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Gateway")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("GatewayCustomerId")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("GatewaySubscriptionId")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Identifier")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("LicenseKey")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("LimitCollectionCreation")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LimitCollectionDeletion")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LimitItemDeletion")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("MaxAutoscalePamSeats")
+ .HasColumnType("int");
+
+ b.Property("MaxAutoscaleSeats")
+ .HasColumnType("int");
+
+ b.Property("MaxAutoscaleSmSeats")
+ .HasColumnType("int");
+
+ b.Property("MaxAutoscaleSmServiceAccounts")
+ .HasColumnType("int");
+
+ b.Property("MaxCollections")
+ .HasColumnType("smallint");
+
+ b.Property("MaxStorageGb")
+ .HasColumnType("smallint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("OrganizationKeyId")
+ .HasMaxLength(32)
+ .HasColumnType("varchar(32)");
+
+ b.Property("OwnersNotifiedOfAutoscaling")
+ .HasColumnType("datetime(6)");
+
+ b.Property("PamSeatMinimum")
+ .HasColumnType("int");
+
+ b.Property("PamSeats")
+ .HasColumnType("int");
+
+ b.Property("Plan")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("PlanType")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("PrivateKey")
+ .HasColumnType("longtext");
+
+ b.Property("PublicKey")
+ .HasColumnType("longtext");
+
+ b.Property("ReferenceData")
+ .HasColumnType("longtext");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Seats")
+ .HasColumnType("int");
+
+ b.Property("SelfHost")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("SmSeats")
+ .HasColumnType("int");
+
+ b.Property("SmServiceAccounts")
+ .HasColumnType("int");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Storage")
+ .HasColumnType("bigint");
+
+ b.Property("SyncSeats")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("TwoFactorProviders")
+ .HasColumnType("longtext");
+
+ b.Property("Use2fa")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseAdminSponsoredFamilies")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseApi")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseAutomaticUserConfirmation")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseCustomPermissions")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseDirectory")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseDisableSmAdsForUsers")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseEvents")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseGroups")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseInviteLinks")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseKeyConnector")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseMyItems")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseOrganizationDomains")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePam")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePasswordManager")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePhishingBlocker")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePolicies")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseResetPassword")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseRiskInsights")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseScim")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseSecretsManager")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseSso")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseTotp")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsersGetPremium")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GatewayCustomerId");
+
+ b.HasIndex("GatewaySubscriptionId");
+
+ b.HasIndex("Id", "Enabled")
+ .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" });
+
+ b.ToTable("Organization", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationInviteLink", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AllowedDomains")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Invite")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("SupportsConfirmation")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId")
+ .IsUnique()
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.ToTable("OrganizationInviteLink", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Data")
+ .HasColumnType("longtext");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("OrganizationId", "Type")
+ .IsUnique()
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.ToTable("Policy", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AccessCode")
+ .HasMaxLength(25)
+ .HasColumnType("varchar(25)");
+
+ b.Property("Approved")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("AuthenticationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Key")
+ .HasColumnType("longtext");
+
+ b.Property("MasterPasswordHash")
+ .HasColumnType("longtext");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("PublicKey")
+ .HasColumnType("longtext");
+
+ b.Property("RequestCountryName")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("RequestDeviceIdentifier")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("RequestDeviceType")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("RequestIpAddress")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("ResponseDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ResponseDeviceId")
+ .HasColumnType("char(36)");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.HasIndex("ResponseDeviceId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AuthRequest", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("GranteeId")
+ .HasColumnType("char(36)");
+
+ b.Property("GrantorId")
+ .HasColumnType("char(36)");
+
+ b.Property("KeyEncrypted")
+ .HasColumnType("longtext");
+
+ b.Property("LastNotificationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RecoveryInitiatedDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("WaitTimeDays")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GranteeId");
+
+ b.HasIndex("GrantorId");
+
+ b.ToTable("EmergencyAccess", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("ConsumedDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Data")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Description")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("SessionId")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("SubjectId")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.HasKey("Id")
+ .HasName("PK_Grant")
+ .HasAnnotation("SqlServer:Clustered", true);
+
+ b.HasIndex("ExpirationDate")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("Grant", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Data")
+ .HasColumnType("longtext");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.ToTable("SsoConfig", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ExternalId")
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("OrganizationId", "ExternalId")
+ .IsUnique()
+ .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" })
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("OrganizationId", "UserId")
+ .IsUnique()
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.ToTable("SsoUser", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.TwoFactorRememberToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DeviceId")
+ .HasColumnType("char(36)");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Stamp")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("ExpirationDate")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("UserId", "DeviceId")
+ .IsUnique()
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.ToTable("TwoFactorRememberToken", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AaGuid")
+ .HasColumnType("char(36)");
+
+ b.Property("Counter")
+ .HasColumnType("int");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CredentialId")
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("EncryptedPrivateKey")
+ .HasMaxLength(2000)
+ .HasColumnType("varchar(2000)");
+
+ b.Property("EncryptedPublicKey")
+ .HasMaxLength(2000)
+ .HasColumnType("varchar(2000)");
+
+ b.Property("EncryptedUserKey")
+ .HasMaxLength(2000)
+ .HasColumnType("varchar(2000)");
+
+ b.Property("Name")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("PublicKey")
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("SupportsPrf")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Type")
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("WebAuthnCredential", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("GatewayCustomerId")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("GatewaySubscriptionId")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("MaxAutoscaleSeats")
+ .HasColumnType("int");
+
+ b.Property("MaxStorageGb")
+ .HasColumnType("smallint");
+
+ b.Property