Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8c9dc48
[PM-41931] feat(dirt): Add ReportExclusions column to Cipher
AlexRubik Oct 21, 2026
8b8e9af
[PM-41931] feat(dirt): Return the per-user report exclusion date on c…
AlexRubik Oct 21, 2026
5bba0de
[PM-41931] feat(dirt): Add the cipher report exclusion repository
AlexRubik Oct 21, 2026
e324b97
[PM-41931] feat(dirt): Add the exclude from reports endpoint
AlexRubik Oct 21, 2026
ee40663
[PM-41931] test(dirt): Cover health report exclusions
AlexRubik Oct 21, 2026
80ea39a
fixup! [PM-41931] feat(dirt): Add the cipher report exclusion repository
AlexRubik Oct 21, 2026
9aecee9
fixup! [PM-41931] test(dirt): Cover health report exclusions
AlexRubik Oct 21, 2026
573111a
[PM-41931] fix(dirt): Return report exclusion date in EF sync without…
AlexRubik Oct 21, 2026
c6a8fba
[PM-41931] refactor(dirt): Store report exclusions as a per-user flag
AlexRubik Oct 21, 2026
341805f
[PM-41931] refactor(dirt): Move the exclude endpoint to its own PR
AlexRubik Oct 23, 2026
d06457f
[PM-41931] chore(dirt): Re-date migrations after syncing with main
AlexRubik Oct 23, 2026
90f1645
[PM-41931] refactor(dirt): Rename report exclusions to browser health…
AlexRubik Oct 26, 2026
8b53441
[PM-41931] refactor(dirt): Rename the exclusion procedure to UpdateMany
AlexRubik Oct 26, 2026
e0ffca1
[PM-41931] style(dirt): Align the column types in Cipher.sql
AlexRubik Oct 26, 2026
d363d16
[PM-41931] refactor(dirt): Return early when no cipher ids are passed
AlexRubik Oct 26, 2026
4ca8638
[PM-41931] chore(dirt): Regenerate EF migrations on current main
AlexRubik Oct 26, 2026
f52fc32
[PM-41931] chore(dirt): Re-date the SQL migration after main
AlexRubik Oct 27, 2026
d406148
[PM-41931] feat(dirt): Add the exclude from reports endpoint
AlexRubik Oct 23, 2026
97b6f5d
[PM-41931] refactor(dirt): Rename the exclude from reports endpoint t…
AlexRubik Oct 26, 2026
5216750
[PM-41931] feat(dirt): Add an Exclude parameter to the exclusion proc…
AlexRubik Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}

/// <summary>Excludes the caller's personal login from browser health.</summary>
[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);
}
}
2 changes: 2 additions & 0 deletions src/Api/Vault/Models/Response/CipherResponseModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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; }
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ο»Ώnamespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;

public interface IExcludeCipherFromBrowserHealthCommand
{
/// <summary>Excludes the user's personal login from browser health. Requires premium.</summary>
Task ExcludeAsync(Guid cipherId, Guid userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public static void AddReportingServices(this IServiceCollection services, IGloba
services.AddScoped<IGetOrganizationReportApplicationDataQuery, GetOrganizationReportApplicationDataQuery>();
services.AddScoped<IUpdateOrganizationReportApplicationDataCommand, UpdateOrganizationReportApplicationDataCommand>();
services.AddScoped<IGetPasskeyDirectoryQuery, GetPasskeyDirectoryQuery>();
services.AddScoped<IExcludeCipherFromBrowserHealthCommand, ExcludeCipherFromBrowserHealthCommand>();

// v2 file storage commands
services.AddScoped<ICreateOrganizationReportCommand, CreateOrganizationReportCommand>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ο»Ώnamespace Bit.Core.Dirt.Repositories;

public interface ICipherBrowserHealthExclusionRepository
{
/// <summary>Excludes the user's personal, not-deleted logins from browser health. Returns how many were excluded.</summary>
Task<int> ExcludeAsync(IEnumerable<Guid> cipherIds, Guid userId);
}
1 change: 1 addition & 0 deletions src/Core/Vault/Entities/Cipher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public class Cipher : ITableObject<Guid>, 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()
{
Expand Down
3 changes: 3 additions & 0 deletions src/Core/Vault/Models/Data/CipherDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ public static void AddDapperRepositories(this IServiceCollection services, bool
services.AddSingleton<IOrganizationApplicationRepository, OrganizationApplicationRepository>();
services.AddSingleton<IOrganizationDeleteTaskRepository, OrganizationDeleteTaskRepository>();
services.AddSingleton<IOrganizationMemberBaseDetailRepository, OrganizationMemberBaseDetailRepository>();
services.AddSingleton<ICipherBrowserHealthExclusionRepository, CipherBrowserHealthExclusionRepository>();

if (selfHosted)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<int> ExcludeAsync(IEnumerable<Guid> cipherIds, Guid userId)
{
var ids = cipherIds.ToList();
if (ids.Count == 0)
{
return 0;
}

await using var connection = new SqlConnection(ConnectionString);
return await connection.ExecuteScalarAsync<int>(
"[dbo].[Cipher_UpdateManyBrowserHealthExclusions]",
new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId, Exclude = true },
commandType: CommandType.StoredProcedure);
}
}
Original file line number Diff line number Diff line change
@@ -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<int> ExcludeAsync(IEnumerable<Guid> 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<Guid, bool>()
: CoreHelpers.LoadClassFromJsonData<Dictionary<Guid, bool>>(cipher.BrowserHealthExclusions)
?? new Dictionary<Guid, bool>();
exclusions[userId] = true;
cipher.BrowserHealthExclusions = CoreHelpers.ClassToJsonData(exclusions);
}

if (ciphers.Count > 0)
{
await dbContext.UserBumpAccountRevisionDateAsync(userId);
}
await dbContext.SaveChangesAsync();
return ciphers.Count;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ public static void AddPasswordManagerEFRepositories(this IServiceCollection serv
services.AddSingleton<IOrganizationReportRepository, OrganizationReportRepository>();
services.AddSingleton<IOrganizationApplicationRepository, OrganizationApplicationRepository>();
services.AddSingleton<IOrganizationMemberBaseDetailRepository, OrganizationMemberBaseDetailRepository>();
services.AddSingleton<ICipherBrowserHealthExclusionRepository, CipherBrowserHealthExclusionRepository>();
services.AddSingleton<IOrganizationDeleteTaskRepository, OrganizationDeleteTaskRepository>();

if (selfHosted)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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;
}
Expand All @@ -143,6 +146,7 @@ from cg in cg_g.DefaultIfEmpty()
}
}


private static Guid? GetFolderId(Guid? userId, Cipher cipher)
{
try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ public async Task<ICollection<CipherDetails>> GetManyByUserIdAsync(Guid userId,
OrganizationUseTotp = false,
Key = c.Key,
ArchivedDate = c.ArchivedDate,
ExcludedFromBrowserHealth = c.ExcludedFromBrowserHealth,
};
}

Expand Down Expand Up @@ -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>(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())
Expand Down Expand Up @@ -634,6 +651,7 @@ public async Task ReplaceAsync(CipherDetails cipher)

var mappedEntity = Mapper.Map<Cipher>(cipher);
dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity);
dbContext.Entry(entity).Property(c => c.BrowserHealthExclusions).IsModified = false;

if (cipher.OrganizationId.HasValue)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public virtual IQueryable<CipherDetails> Run(DatabaseContext dbContext)
ArchivedDate = !_userId.HasValue || c.Archives == null || !c.Archives.ToLowerInvariant().Contains(_userId.Value.ToString()) ?
null :
CoreHelpers.LoadClassFromJsonData<Dictionary<Guid, DateTime?>>(c.Archives)[_userId.Value],
ExcludedFromBrowserHealth = _userId.HasValue && c.BrowserHealthExclusions != null && c.BrowserHealthExclusions.ToLowerInvariant().Contains($"\"{_userId}\":true"),
};
return query;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion src/Sql/dbo/Vault/Functions/CipherDetails.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading