Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2bb7c17
initial send controls
harr1424 Mar 2, 2026
6f51c42
update vNext methods and add test coverage for policy validators
harr1424 Mar 3, 2026
94b7025
Merge branch 'main' into tools/PM-31885-SendControls-Policy
harr1424 Mar 3, 2026
7c4c042
Merge remote-tracking branch 'origin/main' into tools/PM-31885-SendCo…
harr1424 Mar 3, 2026
bcc2960
add comments to tests
harr1424 Mar 4, 2026
1f7caf8
Merge branch 'main' into tools/PM-31885-SendControls-Policy
harr1424 Mar 4, 2026
d52424c
Apply suggestion from @mkincaid-bw
harr1424 Mar 6, 2026
235d832
renamne migrations for correct sorting
harr1424 Mar 6, 2026
e6bf8e6
Merge branch 'tools/PM-31885-SendControls-Policy' of github.com:bitwa…
harr1424 Mar 6, 2026
b0af868
Merge branch 'main' into tools/PM-31885-SendControls-Policy
harr1424 Mar 6, 2026
4a0728c
respond to csharp related review comments
harr1424 Mar 6, 2026
16cffeb
fix failing lints
harr1424 Mar 6, 2026
ea8527e
fix tests
harr1424 Mar 6, 2026
783b426
revise policy sync logic
harr1424 Mar 7, 2026
f8bbfa0
revise policy event logic and tests
harr1424 Mar 9, 2026
5022841
Merge branch 'main' into tools/PM-31885-SendControls-Policy
harr1424 Mar 9, 2026
9b1b7e7
Merge branch 'tools/PM-31885-SendControls-Policy' of github.com:bitwa…
harr1424 Mar 9, 2026
a6ea853
add integration tests
harr1424 Mar 10, 2026
e6c1f11
Merge branch 'main' into tools/PM-31885-SendControls-Policy
harr1424 Mar 10, 2026
ac6710d
OR legacy policy data with SendControls policy data
harr1424 Mar 11, 2026
c739d52
Merge branch 'main' into tools/PM-31885-SendControls-Policy
harr1424 Mar 17, 2026
8bc9f7d
remove migrations and associated integration test
harr1424 Mar 17, 2026
26baa10
Merge branch 'main' into tools/PM-31885-SendControls-Policy
harr1424 Mar 18, 2026
b544964
whitespacing and comment correction
harr1424 Mar 18, 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
9 changes: 9 additions & 0 deletions src/Core/AdminConsole/Enums/PolicyType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ public enum PolicyType : byte
SingleOrg = 3,
RequireSso = 4,
OrganizationDataOwnership = 5,
// Deprecated: superseded by SendControls (20) when pm-31885-send-controls flag is active.
// Do not add [Obsolete] until the flag is retired.
DisableSend = 6,
// Deprecated: superseded by SendControls (20) when pm-31885-send-controls flag is active.
// Do not add [Obsolete] until the flag is retired.
SendOptions = 7,
ResetPassword = 8,
MaximumVaultTimeout = 9,
Expand All @@ -22,6 +26,10 @@ public enum PolicyType : byte
AutotypeDefaultSetting = 17,
AutomaticUserConfirmation = 18,
BlockClaimedDomainAccountCreation = 19,
/// <summary>
/// Supersedes DisableSend (6) and SendOptions (7) when the pm-31885-send-controls feature flag is active.
/// </summary>
SendControls = 20,
}

public static class PolicyTypeExtensions
Expand Down Expand Up @@ -54,6 +62,7 @@ public static string GetName(this PolicyType type)
PolicyType.AutotypeDefaultSetting => "Autotype default setting",
PolicyType.AutomaticUserConfirmation => "Automatically confirm invited users",
PolicyType.BlockClaimedDomainAccountCreation => "Block account creation for claimed domains",
PolicyType.SendControls => "Send controls",
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
ο»Ώusing System.ComponentModel.DataAnnotations;

namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies;

public class SendControlsPolicyData : IPolicyDataModel
{
[Display(Name = "DisableSend")]
public bool DisableSend { get; set; }
[Display(Name = "DisableHideEmail")]
public bool DisableHideEmail { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
ο»Ώusing Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;

namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;

/// <summary>
/// Policy requirements for the Send Controls policy.
/// Supersedes DisableSend and SendOptions when the pm-31885-send-controls feature flag is active.
/// </summary>
public class SendControlsPolicyRequirement : IPolicyRequirement
{
/// <summary>
/// Indicates whether Send is disabled for the user. If true, the user should not be able to create or edit Sends.
/// They may still delete existing Sends.
/// </summary>
public bool DisableSend { get; init; }

/// <summary>
/// Indicates whether the user is prohibited from hiding their email from the recipient of a Send.
/// </summary>
public bool DisableHideEmail { get; init; }
}

public class SendControlsPolicyRequirementFactory : BasePolicyRequirementFactory<SendControlsPolicyRequirement>
{
public override PolicyType PolicyType => PolicyType.SendControls;

public override SendControlsPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
{
return policyDetails
.Select(p => p.GetDataModel<SendControlsPolicyData>())
.Aggregate(
new SendControlsPolicyRequirement(),
(result, data) => new SendControlsPolicyRequirement
{
DisableSend = result.DisableSend || data.DisableSend,
DisableHideEmail = result.DisableHideEmail || data.DisableHideEmail,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,16 @@ private static void AddPolicyUpdateEvents(this IServiceCollection services)
services.AddScoped<IPolicyUpdateEvent, UriMatchDefaultPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, BlockClaimedDomainAccountCreationPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, AutomaticUserConfirmationPolicyEventHandler>();
services.AddScoped<IPolicyUpdateEvent, DisableSendSyncPolicyEvent>();
services.AddScoped<IPolicyUpdateEvent, SendOptionsSyncPolicyEvent>();
services.AddScoped<IPolicyUpdateEvent, SendControlsSyncPolicyEvent>();
}

private static void AddPolicyRequirements(this IServiceCollection services)
{
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, DisableSendPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendControlsPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, OrganizationDataOwnershipPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireSsoPolicyRequirementFactory>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
ο»Ώusing Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Utilities;

namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;

/// <summary>
/// Syncs changes to the DisableSend policy into the SendControls policy row.
/// 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 DisableSendSyncPolicyEvent(IPolicyRepository policyRepository) : IOnPolicyPostUpdateEvent
{
public PolicyType Type => PolicyType.DisableSend;

public async Task ExecutePostUpsertSideEffectAsync(
SavePolicyModel policyRequest,
Policy postUpsertedPolicyState,
Policy? previousPolicyState)
{
var organizationId = policyRequest.PolicyUpdate.OrganizationId;

// Step 1: sync DisableSend.Enabled -> SendControlsPolicy.Data.DisableSend
var sendControlsPolicy = await policyRepository.GetByOrganizationIdTypeAsync(
organizationId, PolicyType.SendControls) ?? new Policy
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
Type = PolicyType.SendControls,
};

var sendOptionsPolicy = await policyRepository.GetByOrganizationIdTypeAsync(
organizationId, PolicyType.SendOptions);

var sendControlsPolicyData = sendControlsPolicy.GetDataModel<SendControlsPolicyData>();
sendControlsPolicyData.DisableSend = postUpsertedPolicyState.Enabled;
if (sendOptionsPolicy?.Enabled == true)
{
sendControlsPolicyData.DisableHideEmail =
sendOptionsPolicy.GetDataModel<SendOptionsPolicyData>().DisableHideEmail;
}

sendControlsPolicy.SetDataModel(sendControlsPolicyData);

// Step 2: sync Enabled status. SendControlsPolicy is enabled if either legacy policy is enabled
sendControlsPolicy.Enabled = postUpsertedPolicyState.Enabled || (sendOptionsPolicy?.Enabled ?? false);

await policyRepository.UpsertAsync(sendControlsPolicy);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
ο»Ώusing Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Utilities;

namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;

/// <summary>
/// When the pm-31885-send-controls flag is active, syncs changes to the SendControls policy
/// back into the legacy DisableSend and SendOptions policy rows, enabling safe rollback.
/// </summary>
public class SendControlsSyncPolicyEvent(
IPolicyRepository policyRepository,
TimeProvider timeProvider) : IOnPolicyPostUpdateEvent
{
public PolicyType Type => PolicyType.SendControls;

public async Task ExecutePostUpsertSideEffectAsync(
SavePolicyModel policyRequest,
Policy postUpsertedPolicyState,
Policy? previousPolicyState)
{
var policyUpdate = policyRequest.PolicyUpdate;

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

var sendControlsPolicyData =
sendControlsPolicy.GetDataModel<SendControlsPolicyData>();
Comment on lines +28 to +37
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@eliykat could this be obtained directly using postUpsertedPolicyState? I might be confused, but I vaguely recall that using policyRepository was a deliberate change here, although looking this over it does appear redundant.


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

var sendOptionsData = new SendOptionsPolicyData { DisableHideEmail = sendControlsPolicyData.DisableHideEmail };
await UpsertLegacyPolicyAsync(
policyRequest.PolicyUpdate.OrganizationId,
PolicyType.SendOptions,
enabled: postUpsertedPolicyState.Enabled && sendControlsPolicyData.DisableHideEmail,
policyData: CoreHelpers.ClassToJsonData(sendOptionsData));
}

private async Task UpsertLegacyPolicyAsync(
Guid organizationId,
PolicyType type,
bool enabled,
string? policyData)
{
var existing = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, type);

var policy = existing ?? new Policy { OrganizationId = organizationId, Type = type, };

if (existing == null)
{
policy.SetNewId();
}

policy.Enabled = enabled;
policy.Data = policyData;
Comment on lines +68 to +69
Copy link
Member

Choose a reason for hiding this comment

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

Also need to bump the RevisionDate (making sure to use TimeProvider).

policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;

await policyRepository.UpsertAsync(policy);
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Feedback on the DisableSend version of this class is also relevant here.

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
ο»Ώusing Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Utilities;

namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;

/// <summary>
/// Syncs changes to the SendOptions policy into the SendControls policy row.
/// 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 SendOptionsSyncPolicyEvent(IPolicyRepository policyRepository) : IOnPolicyPostUpdateEvent
{
public PolicyType Type => PolicyType.SendOptions;

public async Task ExecutePostUpsertSideEffectAsync(
SavePolicyModel policyRequest,
Policy postUpsertedPolicyState,
Policy? previousPolicyState)
{
var organizationId = policyRequest.PolicyUpdate.OrganizationId;

// Step 1: sync SendOptionsPolicy.Data.DisableHideEmail -> SendControlsPolicy.Data.DisableHideEmail
var sendControlsPolicy = await policyRepository.GetByOrganizationIdTypeAsync(
organizationId, PolicyType.SendControls) ?? new Policy
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
Type = PolicyType.SendControls,
};

var sendControlsPolicyData = sendControlsPolicy.GetDataModel<SendControlsPolicyData>();
sendControlsPolicyData.DisableHideEmail = postUpsertedPolicyState.GetDataModel<SendOptionsPolicyData>().DisableHideEmail;
sendControlsPolicy.SetDataModel(sendControlsPolicyData);

// Step 2: sync Enabled status. SendControlsPolicy is enabled if either legacy policy is enabled
// Optimization: DisableSendPolicy.Enabled maps to SendControlsPolicy.Data.DisableSend - so we can use that
// as a proxy for that legacy policy state
sendControlsPolicy.Enabled = postUpsertedPolicyState.Enabled ||
sendControlsPolicyData.DisableSend;

await policyRepository.UpsertAsync(sendControlsPolicy);
}
}
33 changes: 31 additions & 2 deletions src/Core/Tools/SendFeatures/Services/SendValidationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public class SendValidationService : ISendValidationService
private readonly IUserRepository _userRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IUserService _userService;
private readonly IFeatureService _featureService;
private readonly GlobalSettings _globalSettings;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
Expand All @@ -26,13 +27,15 @@ public SendValidationService(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IUserService userService,
IFeatureService featureService,
IPolicyRequirementQuery policyRequirementQuery,
GlobalSettings globalSettings,
IPricingClient pricingClient)
{
_userRepository = userRepository;
_organizationRepository = organizationRepository;
_userService = userService;
_featureService = featureService;
_policyRequirementQuery = policyRequirementQuery;
_globalSettings = globalSettings;
_pricingClient = pricingClient;
Expand All @@ -47,13 +50,39 @@ public async Task ValidateUserCanSaveAsync(Guid? userId, Send send)
return;
}

var disableSendRequirement = await _policyRequirementQuery.GetAsync<DisableSendPolicyRequirement>(userId.Value);
#region Fetch Policy Requirements Async
var sendControlsTask = _policyRequirementQuery.GetAsync<SendControlsPolicyRequirement>(userId.Value);
var disableSendTask = _policyRequirementQuery.GetAsync<DisableSendPolicyRequirement>(userId.Value);
var sendOptionsTask = _policyRequirementQuery.GetAsync<SendOptionsPolicyRequirement>(userId.Value);

await Task.WhenAll(sendControlsTask, disableSendTask, sendOptionsTask);

var sendControlsRequirement = sendControlsTask.Result;
var disableSendRequirement = disableSendTask.Result;
var sendOptionsRequirement = sendOptionsTask.Result;
#endregion

if (_featureService.IsEnabled(FeatureFlagKeys.SendControls))
{
if (sendControlsRequirement.DisableSend || disableSendRequirement.DisableSend)
{
throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send.");
}

if ((sendControlsRequirement.DisableHideEmail || sendOptionsRequirement.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.");
}

return;
}

if (disableSendRequirement.DisableSend)
{
throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send.");
}

var sendOptionsRequirement = await _policyRequirementQuery.GetAsync<SendOptionsPolicyRequirement>(userId.Value);
if (sendOptionsRequirement.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.");
Expand Down
Loading
Loading