Skip to content

[PM-31885] Consolidate all Send policies to a single policy#7113

Draft
harr1424 wants to merge 14 commits intomainfrom
tools/PM-31885-SendControls-Policy
Draft

[PM-31885] Consolidate all Send policies to a single policy#7113
harr1424 wants to merge 14 commits intomainfrom
tools/PM-31885-SendControls-Policy

Conversation

@harr1424
Copy link
Contributor

@harr1424 harr1424 commented Mar 2, 2026

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-31885

📔 Objective

Consolidate all Send policies, including new policies, to a single policy.

Please see TBD for full context.

This PR introduces a new SendControls policy, that is intended to absorb the existing Send policies DisableSend and DisableHideEmail and serve as a container for upcoming Send related policies as part of PM-31883.

Of note in this PR are multiple PolicyValidators used to ensure policy statuses are synced between the old and new implementation across feature flag status changes or potential rollbacks.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 2, 2026

Logo
Checkmarx One – Scan Summary & Details7c1c0c2f-9880-434e-b21f-c5e79ae8e99a


New Issues (1) Checkmarx found the following issues in this Pull Request
# Severity Issue Source File / Package Checkmarx Insight
1 MEDIUM CSRF /src/Api/Auth/Controllers/AccountsController.cs: 291
detailsMethod at line 291 of /src/Api/Auth/Controllers/AccountsController.cs gets a parameter from a user request from model. This parameter value flow...
Attack Vector

Fixed Issues (1) Great job! The following issues were fixed in this Pull Request
Severity Issue Source File / Package
MEDIUM CSRF /src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs: 146

@codecov
Copy link

codecov bot commented Mar 2, 2026

Codecov Report

❌ Patch coverage is 98.33333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.02%. Comparing base (fa5fde5) to head (783b426).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/Core/AdminConsole/Enums/PolicyType.cs 0.00% 1 Missing ⚠️
...es/PolicyValidators/SendControlsSyncPolicyEvent.cs 97.43% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7113      +/-   ##
==========================================
+ Coverage   56.87%   61.02%   +4.14%     
==========================================
  Files        2028     2033       +5     
  Lines       88827    88946     +119     
  Branches     7917     7935      +18     
==========================================
+ Hits        50523    54276    +3753     
+ Misses      36472    32756    -3716     
- Partials     1832     1914      +82     

☔ View full report in Codecov by Sentry.
📢 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.

@harr1424 harr1424 marked this pull request as ready for review March 2, 2026 15:55
@harr1424 harr1424 requested review from a team as code owners March 2, 2026 15:55
@harr1424 harr1424 requested a review from eliykat March 2, 2026 15:55
@harr1424
Copy link
Contributor Author

harr1424 commented Mar 2, 2026

@claude please review

@claude
Copy link
Contributor

claude bot commented Mar 2, 2026

Claude finished @harr1424's task in 5m 16s —— View job


Code Review: PM-31885 Consolidate all Send policies to a single policy

  • Gather PR diff and read changed files
  • Read existing code for context (existing policy validators, SendValidationService, etc.)
  • Analyze changes and identify issues
  • Post review feedback

Summary

This PR introduces a new SendControls policy (type 20) that consolidates DisableSend (6) and SendOptions (7) into a single policy. It includes bidirectional sync validators to keep the legacy and new policy rows consistent, a new SendControlsPolicyRequirement for the policy requirements framework, a feature-flagged validation path in SendValidationService, data migrations for all DB providers, and the pm-31885-send-controls feature flag.

Overall, the architecture is sound. The bidirectional sync approach via IOnPolicyPostUpdateEvent handlers is safe — policyRepository.UpsertAsync() does not re-trigger event handlers, so there's no infinite loop risk. The migration SQL is correct, idempotent, and batched for MSSQL.


Issues

1. ValidateUserCanSaveAsync_SendControls uses legacy IPolicyService instead of IPolicyRequirementQuery — Design concern

src/Core/Tools/SendFeatures/Services/SendValidationService.cs:93-117

The new ValidateUserCanSaveAsync_SendControls method uses the legacy _policyService.GetPoliciesApplicableToUserAsync approach with manual JSON deserialization. This is inconsistent — a SendControlsPolicyRequirement and SendControlsPolicyRequirementFactory are defined and registered but never consumed. The _vNext method already demonstrates the cleaner pattern using _policyRequirementQuery.GetAsync<T>().

Consider rewriting the method to use the policy requirements pattern:

private async Task ValidateUserCanSaveAsync_SendControls(Guid? userId, Send send)
{
    if (!userId.HasValue)
    {
        return;
    }

    var requirement = await _policyRequirementQuery.GetAsync<SendControlsPolicyRequirement>(userId.Value);
    if (requirement.DisableSend)
    {
        throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send.");
    }

    if (requirement.DisableHideEmail && send.HideEmail.GetValueOrDefault())
    {
        throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.");
    }
}

This eliminates the _currentContext.Organizations check (which BasePolicyRequirementFactory handles via its exemption logic), avoids repeated JSON deserialization, and actually uses the registered SendControlsPolicyRequirementFactory. Fix this →

2. DisableSendSyncPolicyValidator sets DisableSend = postUpsertedPolicyState.Enabled without considering the disabled case properly — Bug

src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/DisableSendSyncPolicyValidator.cs:35

data.DisableSend = postUpsertedPolicyState.Enabled;

When the DisableSend legacy policy is disabled (Enabled = false), this sets data.DisableSend = false in the SendControls row. This is correct. However, it then computes:

policy.Enabled = data.DisableSend || data.DisableHideEmail;

This means if DisableHideEmail is true, the SendControls policy stays enabled — which is the correct behavior. No bug here on closer inspection. ✅

3. No unit tests for the sync validators or the new validation path — Test gap

Missing test files

The project's CLAUDE.md states "ALWAYS add unit tests (with mocking) for any new feature development." The Codecov report shows:

  • SendControlsSyncPolicyValidator.cs: 10.81% coverage (33 lines missing)
  • SendOptionsSyncPolicyValidator.cs: 7.69% coverage (24 lines missing)
  • SendControlsPolicyRequirement.cs: 0% coverage (14 lines missing)
  • SendValidationService.cs (new code): 0% coverage (22 lines missing)

The DisableSendSyncPolicyValidator has 91.3% coverage, which is good, but the other components need corresponding test classes. Key scenarios to test:

  • Sync validators: creation of new SendControls row when none exists, updating existing row, correct Enabled flag computation
  • SendControlsPolicyRequirementFactory.Create: empty input, single policy, multiple policies with OR aggregation
  • ValidateUserCanSaveAsync_SendControls: DisableSend enforcement, DisableHideEmail enforcement, no-user early return

4. SendOptionsSyncPolicyValidator — conditional parsing is asymmetric with DisableSendSyncPolicyValidator — Minor

src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendOptionsSyncPolicyValidator.cs:29-31

var parsedSendOptions = postUpsertedPolicyState.Enabled
    ? CoreHelpers.LoadClassFromJsonData<SendOptionsPolicyData>(postUpsertedPolicyState.Data)
    : null;

When the SendOptions policy is disabled, parsedSendOptions is set to null, which causes data.DisableHideEmail = parsedSendOptions?.DisableHideEmail ?? false to resolve to false. This is intentionally correct — disabling SendOptions should clear DisableHideEmail.

However, DisableSendSyncPolicyValidator does not have equivalent conditional logic — it always sets data.DisableSend = postUpsertedPolicyState.Enabled, which also works because DisableSend has no data payload. Just noting the asymmetry for clarity — it's correct, but a brief comment in DisableSendSyncPolicyValidator explaining "DisableSend has no data model; Enabled state is the entire signal" would help future readers.

5. SendControlsSyncPolicyValidator sets DisableSend legacy policy's Data to null — Minor

src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendControlsSyncPolicyValidator.cs:40-41

await UpsertLegacyPolicyAsync(
    policyRequest.PolicyUpdate.OrganizationId,
    PolicyType.DisableSend,
    enabled: postUpsertedPolicyState.Enabled && data.DisableSend,
    policyData: null);

Setting policyData: null for the DisableSend policy is correct — the legacy DisableSend policy has no data model. Just confirming this is consistent with how DisableSend works (it only uses the Enabled flag). ✅

6. SQLite UUID generation is non-standard — Low risk

util/SqliteMigrations/Migrations/20260228000000_CreateSendControlsPolicies.cs:22-25

The SQLite migration generates UUIDs via randomblob() with string concatenation. While this produces a valid UUID v4 format, it's a somewhat fragile approach. This is consistent with how other SQLite migrations in the codebase handle UUID generation, so it's fine as a convention, but worth noting.

7. MySQL migration uses two separate INSERT statements instead of FULL OUTER JOIN — Correct approach

util/MySqlMigrations/Migrations/20260228000000_CreateSendControlsPolicies.cs

MySQL doesn't support FULL OUTER JOIN, so the migration correctly splits into two statements (orgs with SendOptions, then orgs with DisableSend-only). The MSSQL and PostgreSQL migrations use FULL OUTER JOIN. The logic is equivalent across all providers. ✅

8. Feature flag precedence in ValidateUserCanSaveAsync — Potential concern

src/Core/Tools/SendFeatures/Services/SendValidationService.cs:59-69

if (_featureService.IsEnabled(FeatureFlagKeys.SendControls))
{
    await ValidateUserCanSaveAsync_SendControls(userId, send);
    return;
}

if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements))
{
    await ValidateUserCanSaveAsync_vNext(userId, send);
    return;
}

The SendControls flag takes precedence over PolicyRequirements. This means if both flags are enabled, the SendControls path runs. This is the intended behavior since SendControls is the newer path, but it's worth documenting this precedence explicitly in a comment since the relationship between these two flags isn't obvious.


Suggestions (non-blocking)

  1. Consider using SendControlsPolicyRequirement in the validation service (Issue Identity Server  #1 above). The requirement factory is defined and registered but unused — the validation method should consume it to avoid maintaining two parallel patterns.

  2. Add unit tests for the sync validators, requirement factory, and validation path. This is especially important for the sync validators which are the core of the rollback safety mechanism.

  3. Add a brief comment in ValidateUserCanSaveAsync explaining the feature flag precedence chain: SendControls > PolicyRequirements > legacy.


Architecture Assessment

The bidirectional sync design is well-thought-out:

  • Legacy → New direction (DisableSendSync, SendOptionsSync): Always runs, ensuring SendControls is kept current regardless of flag state.
  • New → Legacy direction (SendControlsSync): Only runs when the flag is active, enabling safe rollback.
  • No infinite loop risk: policyRepository.UpsertAsync() is a direct DB call that doesn't trigger event handlers.
  • Migration is idempotent: All SQL uses WHERE NOT EXISTS guards.

The overall approach is solid for a gradual migration with rollback safety.

@harr1424 harr1424 marked this pull request as draft March 2, 2026 16:39
@harr1424 harr1424 marked this pull request as ready for review March 4, 2026 16:17
harr1424 and others added 4 commits March 5, 2026 19:56
• `IX_Policy_OrganizationId_Type` is a unique index

Co-authored-by: mkincaid-bw <mkincaid@bitwarden.com>
…rden/server into tools/PM-31885-SendControls-Policy

merge suggested changes to migration
Copy link
Member

@eliykat eliykat left a comment

Choose a reason for hiding this comment

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

Nice use of the policy framework. General approach looks good to me.

@@ -0,0 +1,54 @@
#nullable enable
Copy link
Member

Choose a reason for hiding this comment

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

Not required - nullable is the default.

policy.SetNewId();
}

policy.Enabled = data.DisableSend || data.DisableHideEmail;
Copy link
Member

Choose a reason for hiding this comment

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

The configuration of a policy (in its data) is separate to its enabled/disabled state. I think the proper mapping would be: sendControlsPolicy.Enabled = disableSendPolicy.Enabled || SendOptionsPolicy.Enabled. (not the variable names you have here, example only)

This doesn't make a difference for DisableSend, because it's mapped from the Enabled state anyway, but it does for DisableHideEmail.

Comment on lines +38 to +42
var policy = sendControlsPolicy ?? new Policy
{
OrganizationId = policyUpdate.OrganizationId,
Type = PolicyType.SendControls,
};
Copy link
Member

Choose a reason for hiding this comment

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

All the different policy objects floating around here are a bit confusing. Maybe handle this upfront:

var sendControlsPolicy = await policyRepository.GetByOrganizationIdTypeAsync(
            policyUpdate.OrganizationId, PolicyType.SendControls) ?? new Policy
        {
            Id = CoreHelpers.GenerateComb(),
            OrganizationId = policyUpdate.OrganizationId,
            Type = PolicyType.SendControls,
        };

So you can then just deal with this object without worrying whether it existed or not.

/// Runs regardless of the pm-31885-send-controls feature flag to ensure SendControls
/// always stays current for when the flag is eventually enabled.
/// </summary>
public class DisableSendSyncPolicyValidator(IPolicyRepository policyRepository) : IOnPolicyPostUpdateEvent
Copy link
Member

Choose a reason for hiding this comment

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

The existing classes are also Validators, but this is not. A more accurate name could be something like

Suggested change
public class DisableSendSyncPolicyValidator(IPolicyRepository policyRepository) : IOnPolicyPostUpdateEvent
public class DisableSendSyncUpdateEvent(IPolicyRepository policyRepository) : IOnPolicyPostUpdateEvent

p.OrganizationId == policyUpdate.OrganizationId &&
p.Type == PolicyType.SendControls &&
p.Enabled == true &&
(CoreHelpers.LoadClassFromJsonData<SendControlsPolicyData>(p.Data)!.DisableSend == true)));
Copy link
Member

Choose a reason for hiding this comment

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

Tests should also use the getter to access the data model.

Comment on lines +36 to +66
SELECT DISTINCT
COALESCE(ds.OrganizationId, so.OrganizationId) AS OrganizationId,
ds.Enabled AS DisableSendEnabled,
so.Enabled AS SendOptionsEnabled,
so.Data AS SendOptionsData
FROM
[dbo].[Policy] ds
LEFT JOIN
[dbo].[Policy] so
ON ds.OrganizationId = so.OrganizationId
AND so.Type = @SendOptionsType
WHERE
ds.Type = @DisableSendType
UNION
SELECT
so.OrganizationId,
NULL,
so.Enabled,
so.Data
FROM
[dbo].[Policy] so
WHERE
so.Type = @SendOptionsType
AND NOT EXISTS (
SELECT
1
FROM
[dbo].[Policy] ds
WHERE
ds.OrganizationId = so.OrganizationId
AND ds.Type = @DisableSendType
Copy link
Member

Choose a reason for hiding this comment

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

dbops will be a better source of advice on this, but the design of this query is a little unusual to me. "select send options with left join on disable send, but also UNION with select disable send where there are no send options". Couldn't we just select send options UNION select disable send?

Comment on lines +19 to +22
SELECT lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
substr(lower(hex(randomblob(2))),2) || '-' ||
substr('89ab',abs(random()) % 4 + 1, 1) ||
substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))),
Copy link
Member

Choose a reason for hiding this comment

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

Again I will defer to dbops, but this doens't seem like the right way to generate guids.

Comment on lines +23 to +32
-- Build JSON: use ISJSON guard for SendOptions.Data
N'{"disableSend":' +
CASE WHEN ISNULL(combined.DisableSendEnabled, 0) = 1
THEN N'true' ELSE N'false' END +
N',"disableHideEmail":' +
CASE WHEN combined.SendOptionsData IS NOT NULL
AND ISJSON(combined.SendOptionsData) = 1
AND JSON_VALUE(combined.SendOptionsData, '$.disableHideEmail') = 'true'
THEN N'true' ELSE N'false' END +
N'}',
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

Data migrations can be pretty high risk - definitely talk to dbops about this. They may want to run it manually to control/monitor performance if it is expected to take a while (especially with json parsing).

You should also write integration tests for this - see https://contributing.bitwarden.com/contributing/testing/database/#testing-data-migrations. Note that these tests are not permanent, but they give you confidence when merging your PR - especially in a non-trivial data migration script like this one, with separate implementations per database provider.

Comment on lines +29 to +32
if (!featureService.IsEnabled(FeatureFlagKeys.SendControls))
{
return;
}
Copy link
Member

Choose a reason for hiding this comment

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

Not sure if this guard is needed - you don't expect any updates if the feature flag is off, sure, but if you get one - wouldn't you still want to sync it?

Note that the feature flag state in clients can lag behind server (by whatever the refresh interval is), so it's possible that an admin edits the Send Controls policy on the front-end even though the feature flag has been turned off.

@harr1424 harr1424 marked this pull request as draft March 6, 2026 16:04
@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 7, 2026

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants