Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
40d49f7
[PM-41931] feat(dirt): Add ReportExclusions column to Cipher
AlexRubik Oct 21, 2026
1ffa045
[PM-41931] feat(dirt): Return the per-user report exclusion date on c…
AlexRubik Oct 21, 2026
562b531
[PM-41931] feat(dirt): Add the cipher report exclusion repository
AlexRubik Oct 21, 2026
cbc2cb9
[PM-41931] feat(dirt): Add the exclude from reports endpoint
AlexRubik Oct 21, 2026
12e2aca
[PM-41931] test(dirt): Cover health report exclusions
AlexRubik Oct 21, 2026
72dc897
fixup! [PM-41931] feat(dirt): Add the cipher report exclusion repository
AlexRubik Oct 21, 2026
a7cb596
fixup! [PM-41931] test(dirt): Cover health report exclusions
AlexRubik Oct 21, 2026
e160edf
[PM-41931] fix(dirt): Return report exclusion date in EF sync without…
AlexRubik Oct 21, 2026
0112576
[PM-41931] refactor(dirt): Store report exclusions as a per-user flag
AlexRubik Oct 21, 2026
17ad7d9
[PM-41931] refactor(dirt): Move the exclude endpoint to its own PR
AlexRubik Oct 23, 2026
36ec325
[PM-41931] chore(dirt): Re-date migrations after syncing with main
AlexRubik Oct 23, 2026
93dee7a
[PM-41931] refactor(dirt): Rename report exclusions to browser health…
AlexRubik Oct 26, 2026
fdf9e49
[PM-41931] refactor(dirt): Rename the exclusion procedure to UpdateMany
AlexRubik Oct 26, 2026
26f470e
[PM-41931] style(dirt): Align the column types in Cipher.sql
AlexRubik Oct 26, 2026
26496f6
[PM-41931] refactor(dirt): Return early when no cipher ids are passed
AlexRubik Oct 26, 2026
478d9a1
[PM-41931] chore(dirt): Regenerate EF migrations on current main
AlexRubik Oct 26, 2026
64a5baa
[PM-41931] chore(dirt): Re-date the SQL migration after main
AlexRubik Oct 27, 2026
21ab4c8
[PM-41931] feat(dirt): Add an Exclude parameter to the exclusion proc…
AlexRubik Oct 27, 2026
ebf5066
[PM-41931] feat(dirt): Store the excluded risk categories per user
AlexRubik Nov 2, 2026
1c8b82a
[PM-41931] feat(dirt): Add the risk category to the user's exclusions
AlexRubik Nov 3, 2026
81daeaf
[PM-41931] refactor(dirt): Rename the column to Cipher.Exclusions
AlexRubik Nov 3, 2026
8ad269a
[PM-41931] refactor(dirt): Return the user's exclusions as exclusions
AlexRubik Nov 3, 2026
7bbf30b
[PM-41931] test(dirt): Cover an exclude with no cipher ids
AlexRubik Nov 3, 2026
a884832
[PM-41931] fix(dirt): Match the risk category exactly on SQL Server
AlexRubik Nov 4, 2026
e3e4216
[PM-41931] refactor(dirt): Rename the exclusions read parameter
AlexRubik Nov 4, 2026
8fcc161
[PM-41931] refactor(dirt): Accept the risk category as NVARCHAR
AlexRubik Nov 4, 2026
0d82e85
[PM-41931] fix(dirt): Run the exclusions migration after the permissi…
AlexRubik Nov 5, 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
4 changes: 4 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,9 @@ protected CipherResponseModel(
Favorite = cipher.Favorite;
Edit = cipher.Edit;
ArchivedDate = cipher.ArchivedDate;
Exclusions = cipher.ExcludedRiskCategories == null
? null
: JsonSerializer.Deserialize<List<string>>(cipher.ExcludedRiskCategories);
ViewPassword = cipher.ViewPassword;
Permissions = new CipherPermissionsResponseModel(user, cipher, organizationAbility);
}
Expand All @@ -312,6 +315,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 IEnumerable<string>? Exclusions { get; set; }
public CipherPermissionsResponseModel Permissions { get; set; }
}

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, adding the risk category to the user's list once. The category must not be empty. Returns how many were excluded.</summary>
Task<int> ExcludeAsync(IEnumerable<Guid> cipherIds, Guid userId, string riskCategory);
}
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 Exclusions { 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 risk categories (JSON array) from the Exclusions JSON.
public string? ExcludedRiskCategories { 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;
ExcludedRiskCategories = cipher.ExcludedRiskCategories;
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,33 @@
ο»Ώ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, string riskCategory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(riskCategory);

var ids = cipherIds.ToList();
if (ids.Count == 0)
{
return 0;
}

await using var connection = new SqlConnection(ConnectionString);
Comment thread
lastbestdev marked this conversation as resolved.
Resolved
return await connection.ExecuteScalarAsync<int>(
"[dbo].[Cipher_UpdateManyExclusions]",
new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId, Exclude = true, RiskCategory = riskCategory },
commandType: CommandType.StoredProcedure);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
ο»Ώ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, string riskCategory)
{
Comment thread
lastbestdev marked this conversation as resolved.
Resolved
ArgumentException.ThrowIfNullOrWhiteSpace(riskCategory);

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.Exclusions)
Comment thread
AlexRubik marked this conversation as resolved.
Resolved
? new Dictionary<Guid, List<string>>()
: CoreHelpers.LoadClassFromJsonData<Dictionary<Guid, List<string>>>(cipher.Exclusions)
?? new Dictionary<Guid, List<string>>();
if (!exclusions.TryGetValue(userId, out var riskCategories) || riskCategories == null)
{
exclusions[userId] = [riskCategory];
}
else if (!riskCategories.Contains(riskCategory))
{
riskCategories.Add(riskCategory);
}
cipher.Exclusions = CoreHelpers.ClassToJsonData(exclusions);
}

if (ciphers.Count > 0)
{
await dbContext.UserBumpAccountRevisionDateAsync(userId);
}
await dbContext.SaveChangesAsync();
return ciphers.Count;
Comment thread
AlexRubik marked this conversation as resolved.
Resolved
}
}
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.Exclusions
};

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.Exclusions
};

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 }),
ExcludedRiskCategories = GetExcludedRiskCategories(_userId, c.Exclusions)
});
return union;
}
Expand All @@ -143,6 +146,28 @@ from cg in cg_g.DefaultIfEmpty()
}
}


internal static string GetExcludedRiskCategories(Guid? userId, string exclusionsJson)
{
try
{
if (userId.HasValue && !string.IsNullOrWhiteSpace(exclusionsJson))
{
var exclusions = JsonSerializer.Deserialize<Dictionary<Guid, List<string>>>(exclusionsJson);
if (exclusions.TryGetValue(userId.Value, out var riskCategories))
{
return JsonSerializer.Serialize(riskCategories);
}
}

return null;
}
catch
{
return null;
}
Comment thread
AlexRubik marked this conversation as resolved.
Dismissed
}

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

Expand Down Expand Up @@ -574,6 +575,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.Exclusions).IsModified = false;
await dbContext.SaveChangesAsync();
}
}
}

public async Task ReplaceAsync(CipherDetails cipher)
{
using (var scope = ServiceScopeFactory.CreateScope())
Expand Down Expand Up @@ -647,6 +664,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.Exclusions).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],
ExcludedRiskCategories = UserCipherDetailsQuery.GetExcludedRiskCategories(_userId, c.Exclusions),
};
return query;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
CREATE PROCEDURE [dbo].[Cipher_UpdateManyExclusions]
@Ids AS [dbo].[GuidIdArray] READONLY,
@UserId AS UNIQUEIDENTIFIER,
@Exclude AS BIT,
@RiskCategory AS NVARCHAR(50) = NULL
AS
BEGIN
SET NOCOUNT ON

UPDATE
C
SET
[Exclusions] = CASE
-- First exclusion for this user: start the user's array
WHEN @Exclude = 1 AND JSON_QUERY(C.[Exclusions], CONCAT('$."', @UserId, '"')) IS NULL THEN JSON_MODIFY(
COALESCE(C.[Exclusions], N'{}'),
CONCAT('$."', @UserId, '"'),
JSON_QUERY(JSON_MODIFY(N'[]', 'append $', @RiskCategory))
)
-- Category already excluded: keep the value
WHEN @Exclude = 1 AND EXISTS (
SELECT 1
FROM OPENJSON(C.[Exclusions], CONCAT('$."', @UserId, '"'))
WHERE [value] COLLATE Latin1_General_BIN2 = @RiskCategory -- exact match, like EF
) THEN C.[Exclusions]
WHEN @Exclude = 1 THEN JSON_MODIFY(
C.[Exclusions],
CONCAT('append $."', @UserId, '"'),
@RiskCategory
)
-- NULL removes the user's key
ELSE JSON_MODIFY(COALESCE(C.[Exclusions], N'{}'), CONCAT('$."', @UserId, '"'), NULL)
END
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
6 changes: 5 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,10 @@ 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 THEN NULL
ELSE JSON_QUERY(C.[Exclusions], CONCAT('$."', @UserId, '"'))
END [ExcludedRiskCategories]
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
@ExcludedRiskCategories NVARCHAR(MAX) = NULL, -- not used
@Exclusions 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
@ExcludedRiskCategories NVARCHAR(MAX) = NULL, -- not used
@Exclusions NVARCHAR(MAX) = NULL, -- not used
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ SELECT
[Key],
[OrganizationUseTotp],
[ArchivedDate],
[ExcludedRiskCategories],
-- The highest rank holds Manage as 4, Edit as 2 and ViewPassword as 1; each flag is one part of it
(MAX(R.[Rank]) / 2) % 2 AS [Edit],
MAX(R.[Rank]) % 2 AS [ViewPassword],
Expand Down Expand Up @@ -48,5 +49,6 @@ SELECT
[Reprompt],
[Key],
[OrganizationUseTotp],
[ArchivedDate]
[ArchivedDate],
[ExcludedRiskCategories]
END
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
@ExcludedRiskCategories NVARCHAR(MAX) = NULL, -- not used
@Exclusions NVARCHAR(MAX) = NULL -- not used
AS
BEGIN
SET NOCOUNT ON
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@Archives NVARCHAR(MAX) = NULL
@Archives NVARCHAR(MAX) = NULL,
@Exclusions NVARCHAR(MAX) = NULL -- not used
AS
BEGIN
SET NOCOUNT ON
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@Archives NVARCHAR(MAX) = NULL,
@Exclusions NVARCHAR(MAX) = NULL, -- not used
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
Expand Down
Loading
Loading