Skip to content

[PM-41931] feat(dirt): Add browser health exclusion storage - #8669

Open
AlexRubik wants to merge 27 commits into
mainfrom
dirt/pm-41931/report-exclusions-api
Open

AlexRubik wants to merge 27 commits into
mainfrom
dirt/pm-41931/report-exclusions-api

Conversation

@AlexRubik

@AlexRubik AlexRubik commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

HOLDING FOR ENCRYPTION OPEN QUESTION

🎟️ Tracking

PM-41931

📔 Objective

This PR adds the database layer for browser health exclusions. A user can exclude a personal login from the browser health page. The endpoint is in #8700, which is stacked on this PR.

This PR adds:

  • A column, Cipher.Exclusions. It holds { "<userId>": ["weak", "exposed"] }: per user, the risk categories the login was excluded from. It is a per-user JSON map, like Favorites and Archives.
  • A per-user value on CipherDetails (ExcludedRiskCategories) and on the cipher response (exclusions): the user's array, or null when the login is not excluded. The normal sync returns it.
  • Cipher_UpdateManyExclusions and ICipherBrowserHealthExclusionRepository. These are the only writers. They change only the user's own personal, not-deleted logins. An exclude adds the risk category to the user's array, and a category that is already there (exact match) is not added again. An exclude needs a non-empty category: without one, it throws an error instead of saving [null]. A restore does not need a category.
  • MSSQL and EF migrations.

Vault save paths do not write the column. The Vault procedures take the parameter as -- not used, and EF replaces mark it as unmodified. Integration tests cover this on all four databases.

Vault-owned files: Cipher.sql, CipherDetails.sql, eight Cipher procedures, Cipher.cs, CipherDetails.cs, CipherResponseModel.cs and three EF query files.

Users cannot write data until #8700 merges.

Sync

An exclude bumps User.AccountRevisionDate, not Cipher.RevisionDate. The push that tells the user's other devices to sync is in #8700. Vault confirmed this on 2026-11-04: exclusions are per user and do not change the encrypted cipher data. This answers item D6 in the tech breakdown.

@AlexRubik AlexRubik added t:feature Change Type - Feature Development ai-review Request a Claude code review labels Oct 21, 2026
SET NOCOUNT ON

DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"')
DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey)

@aikido-pr-checks aikido-pr-checks Bot Oct 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@UserIdPath is assigned but never read in CipherDetails_Create, so this local variable is unused.

Suggested change
DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey)
Details

✨ AI Reasoning
​The procedure constructs a JSON path in a local variable, but never uses that value. This leaves dead code that can be removed without changing behavior.

Reply on this comment to give feedback or ignore the issue.
More info

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

I re-reviewed the database layer for browser health exclusions at the current head. The scope was the Cipher.Exclusions column and the Cipher_UpdateManyExclusions procedure with its exact-match, NVARCHAR category handling. I also covered the Dapper and EF ICipherBrowserHealthExclusionRepository, the per-user ExcludedRiskCategories projection and the renamed 2026-11-05_00_AddCipherExclusions.sql migration. I checked that no Vault write path can overwrite the column. That covers the Dapper procedures fed whole Cipher/CipherDetails objects (@Exclusions and @ExcludedRiskCategories are accepted and not used), both EF ReplaceAsync overloads, key rotation, UpdateCiphersAsync and the bulk-create paths. The migration also refreshes UserCipherDetails and CipherView, which are the only modules that depend on the changed function and table. This review found nothing new beyond the existing threads.

Code Review Details

No new findings. Open discussions I did not raise again:

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.92929% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.82%. Comparing base (a88325a) to head (0d82e85).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
...ork/Repositories/Queries/UserCipherDetailsQuery.cs 80.00% 3 Missing and 1 partial ⚠️
...c/Api/Vault/Models/Response/CipherResponseModel.cs 75.00% 0 Missing and 1 partial ⚠️
src/Core/Vault/Models/Data/CipherDetails.cs 50.00% 1 Missing ⚠️
...ork/Dirt/CipherBrowserHealthExclusionRepository.cs 97.36% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8669      +/-   ##
==========================================
+ Coverage   68.42%   74.82%   +6.39%     
==========================================
  Files        2741     2743       +2     
  Lines      117469   117565      +96     
  Branches    10769    10781      +12     
==========================================
+ Hits        80379    87965    +7586     
+ Misses      34548    26929    -7619     
- Partials     2542     2671     +129     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@AlexRubik AlexRubik changed the title [PM-41931] feat(dirt): Add health report exclusion storage and endpoint [PM-41931] feat(dirt): Add health report exclusion storage Oct 23, 2026
@AlexRubik
AlexRubik added this pull request to stack #8701 October 23, 2026 15:59
@AlexRubik
AlexRubik marked this pull request as ready for review October 23, 2026 15:59
@AlexRubik
AlexRubik requested review from a team as code owners October 23, 2026 15:59

@lastbestdev lastbestdev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few small things to double check/clean up, otherwise this is looking really good!

I went back and forth with whether or not to request changing the BIT value we're storing in the ReportExclusions JSON into a DATETIME like Archives does, but ultimately decided against because YAGNI 😄

@@ -0,0 +1,33 @@
CREATE PROCEDURE [dbo].[Cipher_ExcludeFromReports]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be named something like Cipher_UpdateManyReportExclusions. See docs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, renamed to Cipher_UpdateManyBrowserHealthExclusions. We also moved every name in this PR from "report exclusions" to "browser health exclusions", so it is clear which feature the data is for.

184e8cd

)
FROM
[dbo].[Cipher] C
INNER JOIN @Ids I ON I.[Id] = C.[Id]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Do we have a cap on the number of ID's somewhere? An update to a large number of rows could need to a table lock escalation, which would be bad.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in this PR, because nothing can send more than one id yet. The only caller is the endpoint in #8700, which takes a single cipher id from the route. If we add a bulk endpoint later, we will cap it at 500 like the archive endpoints.

Comment thread src/Sql/dbo/Vault/Tables/Cipher.sql Outdated
Comment on lines +3 to +18
[Key] VARCHAR(MAX) NULL,
[ArchivedDate] DATETIME2 (7) NULL,
[Archives] NVARCHAR(MAX) NULL,
[ReportExclusions] NVARCHAR(MAX) NULL,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please align the types?

Suggested change
[ReportExclusions] 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,
[ReportExclusions] NVARCHAR(MAX) NULL,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/Sql/dbo/Vault/Tables/Cipher.sql Outdated
[Key] VARCHAR(MAX) NULL,
[ArchivedDate] DATETIME2 (7) NULL,
[Archives] NVARCHAR(MAX) NULL,
[ReportExclusions] NVARCHAR(MAX) NULL,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Is NVARCHAR(MAX) the appropriate type for this? Seems like it's only storing {"<userId>":true}. Do we plan on adding support for Orgs too? If not, this seems like an odd way to store a true/false value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Today only the owner of a personal login can exclude it, so the map has at most one key. We kept the JSON shape on purpose: it matches Favorites and Archives, which the Vault team asked us to follow, and it lets us add per-member exclusions for organization items later without another column on Cipher. Organization support is not planned in this ticket.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mkincaid-bw I think this is a great point and had a similar thought while dev'ing on this. Raising with the team for extra discussion.

UPDATE
[dbo].[Cipher]
SET
[UserId] = NULL,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ When this is moved to an Org, do we intentionally want to keep value in ReportExclusions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that is intentional. The browser health page only checks personal logins, so the value has no effect after a share, and items cannot move back to a personal vault. Clearing it would mean changing the Vault share procedure, which we wanted to avoid.

@withinfocus

Copy link
Copy Markdown
Contributor

I'm concerned this is touching our busiest and largest table for a feature that will be used by a relatively small number of users. Can we challenge the requirement for this, and look to storing this exclusion in a different way in the organization or collection domain, which is the only way Access Intelligence is ... accessible?

@lastbestdev

lastbestdev commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

I'm concerned this is touching our busiest and largest table for a feature that will be used by a relatively small number of users. Can we challenge the requirement for this, and look to storing this exclusion in a different way in the organization or collection domain, which is the only way Access Intelligence is ... accessible?

Hey @withinfocus, a few comments here:

  1. This change relates to the new Vault Health feature available in the browser extension for premium users (limited access for free users as well). It is not related to Access Intelligence in any way.
  2. This solution was discussed with Vault team and @shane-melton, where we were advised to move away from a solution that added a new table for storing User-Cipher exclusions. The concern with that table was that an added JOIN into a new table would slow down sync enough that it should be avoided. (I assume this is the same reason that also prevented Archives and Favorites from becoming a part of this theoretical User-Cipher table, which seems very useful from my database perf. naive perspective.)

@withinfocus

Copy link
Copy Markdown
Contributor

Good to know, thanks -- I thought this was an org-only build. I definitely agree that a new table is not appropriate. This is still pretty high risk change and I mentioned it to my team in case they wanted to weigh in, and DbOps would as well.

@AlexRubik AlexRubik changed the title [PM-41931] feat(dirt): Add health report exclusion storage [PM-41931] feat(dirt): Add browser health exclusion storage Oct 26, 2026
@AlexRubik
AlexRubik force-pushed the dirt/pm-41931/report-exclusions-api branch from 4b1477a to d363d16 Compare October 26, 2026 21:50
@lastbestdev
lastbestdev force-pushed the dirt/pm-41931/report-exclusions-api branch from 4ca8638 to 4b1477a Compare October 27, 2026 17:08
@AlexRubik
AlexRubik force-pushed the dirt/pm-41931/report-exclusions-api branch from 4b1477a to f52fc32 Compare October 27, 2026 17:27

@lastbestdev lastbestdev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few adds that would be nice in the sproc so I don't have to do them and retouch db code again in upcoming PRs

@@ -0,0 +1,33 @@
CREATE PROCEDURE [dbo].[Cipher_UpdateManyBrowserHealthExclusions]
@Ids AS [dbo].[GuidIdArray] READONLY,
@UserId AS UNIQUEIDENTIFIER

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a parameter to set the exclude value with?

Suggested change
@UserId AS UNIQUEIDENTIFIER
@UserId AS UNIQUEIDENTIFIER,
@Exclude AS BIT

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BrowserHealthExclusions] = JSON_MODIFY(
COALESCE(C.[BrowserHealthExclusions], N'{}'),
CONCAT('$."', @UserId, '"'),
CAST(1 AS BIT)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And use value here:

Suggested change
CAST(1 AS BIT)
@Exclude

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-user JSON map of cipher report exclusions, a sibling of Archives.
Vault write procedures accept the new parameter but never write it, so
saving a cipher cannot erase another user's exclusion.
…iphers

Reads this user's entry from ReportExclusions into CipherDetails and the
cipher response, so the standard sync delivers every exclusion. EF
replaces never write the column.
DIRT-owned writer for Cipher.ReportExclusions. Only the user's own
personal, not-deleted logins can be excluded, and only the account
revision is bumped, so other devices sync without an edit conflict.
PUT ciphers/{id}/exclude-from-reports, behind the health report
exclusions flag. Requires premium, and pushes a cipher sync so the
user's other devices pick up the exclusion.
Repository tests run on every database and pin the scope rules, the
per-user read, the revision date behavior, and that Vault replaces keep
the exclusion. Adds command and controller unit tests.
… organizations

The EF GetManyByUserIdAsync branch for users without organizations
rebuilds CipherDetails field by field and dropped ReportExcludedDate, so
sync on Postgres, MySQL and SQLite returned null for users with no org.
MSSQL was unaffected (its procedure selects every column).
Store { "<userId>": true } instead of the exclusion date, matching the
tech breakdown and the Favorites pattern. Nothing reads the date, and a
flag tells the server less. Ciphers now expose a per-user
ExcludedFromReports boolean in place of ReportExcludedDate.
This PR now holds only the database and data-access changes, so it can
merge before the endpoint. The command, controller and their unit tests
move to a stacked PR.
Main gained newer migrations, so the SQL script moves to 2026-10-23 and
the three EF migrations are regenerated on top of main's model.
Follow the SQL naming guide: Cipher_UpdateManyBrowserHealthExclusions.
…edure

Lets the restore feature reuse the procedure. Passing 0 removes the user's key, because the SQL Server read treats any stored value as excluded.
Store { "<userId>": ["<riskCategory>"] } instead of { "<userId>": true }.
The procedure and the EF repository take the risk category, and reads
return the user's array as ExcludedRiskCategories.

An exclude without a risk category now throws instead of saving [null].
Restore does not need a category.
Excluding a login adds the risk category to the user's array instead of
replacing it, matching the tech breakdown's PUT. A category that is
already in the array is kept once. Restore still removes the user's entry.
Use the name agreed in the tech breakdown review: Cipher.Exclusions,
and Cipher_UpdateManyExclusions for the procedure that writes it.
The EF migrations are regenerated as AddCipherExclusions.
Rename the cipher response property to Exclusions, so the JSON field is
exclusions, as the tech breakdown and the SDK cipher model expect.
The duplicate check used the database's case-insensitive collation, so
SQL Server treated "Weak" as a duplicate of "weak" while EF stored both.
Compare with Latin1_General_BIN2 so both tracks match the exact text.
VARCHAR turns non-Latin letters into "?", while EF stores the full text.
NVARCHAR(50) keeps any category the API sends, matching the NVARCHAR(MAX)
column it is written to.
…on-level reads

main added 2026-11-02_00_ApplyHighestPermissionLevelInReads.sql, which
redefines CipherDetails_ReadByIdUserId without ExcludedRiskCategories. Our
2026-10-27 script ran before it, so a new database lost the column.

Re-date the script to 2026-11-05 and copy the current
CipherDetails_ReadByIdUserId into it, so it keeps the highest permission
level from main and adds ExcludedRiskCategories.
@AlexRubik
AlexRubik force-pushed the dirt/pm-41931/report-exclusions-api branch from b93d133 to 0d82e85 Compare November 5, 2026 20:51
@AlexRubik
AlexRubik requested a review from Banrion November 5, 2026 21:21
@AlexRubik AlexRubik added the hold Hold this PR or item until later; DO NOT MERGE label Nov 6, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review hold Hold this PR or item until later; DO NOT MERGE t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants