diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs
index bd6daf7cdffa..ad61782521ab 100644
--- a/src/Core/AdminConsole/Enums/PolicyType.cs
+++ b/src/Core/AdminConsole/Enums/PolicyType.cs
@@ -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,
@@ -22,6 +26,10 @@ public enum PolicyType : byte
AutotypeDefaultSetting = 17,
AutomaticUserConfirmation = 18,
BlockClaimedDomainAccountCreation = 19,
+ ///
+ /// Supersedes DisableSend (6) and SendOptions (7) when the pm-31885-send-controls feature flag is active.
+ ///
+ SendControls = 20,
}
public static class PolicyTypeExtensions
@@ -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",
};
}
}
diff --git a/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendControlsPolicyData.cs b/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendControlsPolicyData.cs
new file mode 100644
index 000000000000..42d55aa40c4e
--- /dev/null
+++ b/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendControlsPolicyData.cs
@@ -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; }
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendControlsPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendControlsPolicyRequirement.cs
new file mode 100644
index 000000000000..229a39028c86
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendControlsPolicyRequirement.cs
@@ -0,0 +1,40 @@
+using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+
+namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+
+///
+/// Policy requirements for the Send Controls policy.
+/// Supersedes DisableSend and SendOptions when the pm-31885-send-controls feature flag is active.
+///
+public class SendControlsPolicyRequirement : IPolicyRequirement
+{
+ ///
+ /// 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.
+ ///
+ public bool DisableSend { get; init; }
+
+ ///
+ /// Indicates whether the user is prohibited from hiding their email from the recipient of a Send.
+ ///
+ public bool DisableHideEmail { get; init; }
+}
+
+public class SendControlsPolicyRequirementFactory : BasePolicyRequirementFactory
+{
+ public override PolicyType PolicyType => PolicyType.SendControls;
+
+ public override SendControlsPolicyRequirement Create(IEnumerable policyDetails)
+ {
+ return policyDetails
+ .Select(p => p.GetDataModel())
+ .Aggregate(
+ new SendControlsPolicyRequirement(),
+ (result, data) => new SendControlsPolicyRequirement
+ {
+ DisableSend = result.DisableSend || data.DisableSend,
+ DisableHideEmail = result.DisableHideEmail || data.DisableHideEmail,
+ });
+ }
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs
index a7657dc71477..4441d1aca82c 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs
@@ -63,12 +63,16 @@ private static void AddPolicyUpdateEvents(this IServiceCollection services)
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
}
private static void AddPolicyRequirements(this IServiceCollection services)
{
services.AddScoped, DisableSendPolicyRequirementFactory>();
services.AddScoped, SendOptionsPolicyRequirementFactory>();
+ services.AddScoped, SendControlsPolicyRequirementFactory>();
services.AddScoped, ResetPasswordPolicyRequirementFactory>();
services.AddScoped, OrganizationDataOwnershipPolicyRequirementFactory>();
services.AddScoped, RequireSsoPolicyRequirementFactory>();
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/DisableSendSyncPolicyEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/DisableSendSyncPolicyEvent.cs
new file mode 100644
index 000000000000..ea53ac0515b8
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/DisableSendSyncPolicyEvent.cs
@@ -0,0 +1,55 @@
+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;
+
+///
+/// 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.
+///
+public class DisableSendSyncPolicyEvent(IPolicyRepository policyRepository) : IOnPolicyPostUpdateEvent
+{
+ public PolicyType Type => PolicyType.DisableSend;
+
+ 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 sendOptionsPolicy = await policyRepository.GetByOrganizationIdTypeAsync(
+ policyUpdate.OrganizationId, PolicyType.SendOptions) ?? new Policy
+ {
+ Id = CoreHelpers.GenerateComb(),
+ OrganizationId = policyUpdate.OrganizationId,
+ Type = PolicyType.SendOptions,
+ };
+
+ var sendControlsPolicyData =
+ sendControlsPolicy.GetDataModel();
+
+ var sendOptionsPolicyData = sendOptionsPolicy.GetDataModel();
+
+ sendControlsPolicyData.DisableSend = postUpsertedPolicyState.Enabled;
+ sendControlsPolicyData.DisableHideEmail = sendOptionsPolicy.Enabled && sendOptionsPolicyData.DisableHideEmail;
+ sendControlsPolicy.Enabled = sendControlsPolicyData.DisableSend || sendControlsPolicyData.DisableHideEmail;
+ sendControlsPolicy.SetDataModel(sendControlsPolicyData);
+
+ await policyRepository.UpsertAsync(sendControlsPolicy);
+ }
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendControlsSyncPolicyEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendControlsSyncPolicyEvent.cs
new file mode 100644
index 000000000000..ab186a42ad06
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendControlsSyncPolicyEvent.cs
@@ -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;
+
+///
+/// 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.
+///
+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();
+
+ 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;
+ policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
+
+ await policyRepository.UpsertAsync(policy);
+ }
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendOptionsSyncPolicyEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendOptionsSyncPolicyEvent.cs
new file mode 100644
index 000000000000..f8504e556265
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendOptionsSyncPolicyEvent.cs
@@ -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;
+
+///
+/// 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.
+///
+public class SendOptionsSyncPolicyEvent(IPolicyRepository policyRepository) : IOnPolicyPostUpdateEvent
+{
+ public PolicyType Type => PolicyType.SendOptions;
+
+ 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();
+
+ var disableSendPolicyState = await policyRepository.GetByOrganizationIdTypeAsync(
+ policyUpdate.OrganizationId, PolicyType.DisableSend);
+
+ sendControlsPolicyData.DisableSend = disableSendPolicyState?.Enabled ?? false;
+ sendControlsPolicyData.DisableHideEmail = postUpsertedPolicyState.Enabled && postUpsertedPolicyState.GetDataModel().DisableHideEmail;
+ sendControlsPolicy.Enabled = sendControlsPolicyData.DisableSend || sendControlsPolicyData.DisableHideEmail;
+ sendControlsPolicy.SetDataModel(sendControlsPolicyData);
+
+ await policyRepository.UpsertAsync(sendControlsPolicy);
+ }
+}
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 08d7f6c29254..ef713ff63b65 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -244,6 +244,7 @@ public static class FeatureFlagKeys
public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe";
public const string SendUIRefresh = "pm-28175-send-ui-refresh";
public const string SendEmailOTP = "pm-19051-send-email-verification";
+ public const string SendControls = "pm-31885-send-controls";
/* Vault Team */
public const string CipherKeyEncryption = "cipher-key-encryption";
diff --git a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs
index 506cb00160ef..bf2f91ef16c8 100644
--- a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs
+++ b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs
@@ -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;
@@ -26,6 +27,7 @@ public SendValidationService(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IUserService userService,
+ IFeatureService featureService,
IPolicyRequirementQuery policyRequirementQuery,
GlobalSettings globalSettings,
IPricingClient pricingClient)
@@ -33,6 +35,7 @@ public SendValidationService(
_userRepository = userRepository;
_organizationRepository = organizationRepository;
_userService = userService;
+ _featureService = featureService;
_policyRequirementQuery = policyRequirementQuery;
_globalSettings = globalSettings;
_pricingClient = pricingClient;
@@ -47,6 +50,23 @@ public async Task ValidateUserCanSaveAsync(Guid? userId, Send send)
return;
}
+ if (_featureService.IsEnabled(FeatureFlagKeys.SendControls))
+ {
+ var sendControlsRequirement = await _policyRequirementQuery.GetAsync(userId.Value);
+
+ if (sendControlsRequirement.DisableSend)
+ {
+ throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send.");
+ }
+
+ if (sendControlsRequirement.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;
+ }
+
var disableSendRequirement = await _policyRequirementQuery.GetAsync(userId.Value);
if (disableSendRequirement.DisableSend)
{
diff --git a/test/Api.Test/Tools/SendControlsPolicyIntegrationTests/CreateSendControlsPolicyMigrationTest.cs b/test/Api.Test/Tools/SendControlsPolicyIntegrationTests/CreateSendControlsPolicyMigrationTest.cs
new file mode 100644
index 000000000000..652071d97415
--- /dev/null
+++ b/test/Api.Test/Tools/SendControlsPolicyIntegrationTests/CreateSendControlsPolicyMigrationTest.cs
@@ -0,0 +1,3 @@
+// TODO remove after testing
+// This file will be included to demonstrate testing performed to code reviewers, but should not be merged
+
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendControlsPolicyRequirementFactoryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendControlsPolicyRequirementFactoryTests.cs
new file mode 100644
index 000000000000..c717aa2cf0ed
--- /dev/null
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendControlsPolicyRequirementFactoryTests.cs
@@ -0,0 +1,99 @@
+using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+using Bit.Core.Test.AdminConsole.AutoFixture;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Xunit;
+
+namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+
+[SutProviderCustomize]
+public class SendControlsPolicyRequirementFactoryTests
+{
+ [Theory, BitAutoData]
+ public void DisableSend_IsFalse_IfNoPolicies(SutProvider sutProvider)
+ {
+ var actual = sutProvider.Sut.Create([]);
+
+ Assert.False(actual.DisableSend);
+ }
+
+ [Theory, BitAutoData]
+ public void DisableSend_IsFalse_WhenNotConfigured(
+ [PolicyDetails(PolicyType.SendControls)] PolicyDetails[] policies,
+ SutProvider sutProvider)
+ {
+ foreach (var policy in policies)
+ {
+ policy.SetDataModel(new SendControlsPolicyData { DisableSend = false, DisableHideEmail = false });
+ }
+
+ var actual = sutProvider.Sut.Create(policies);
+
+ Assert.False(actual.DisableSend);
+ }
+
+ [Theory, BitAutoData]
+ public void DisableSend_IsTrue_IfAnyPolicyHasDisableSend(
+ [PolicyDetails(PolicyType.SendControls)] PolicyDetails[] policies,
+ SutProvider sutProvider)
+ {
+ policies[0].SetDataModel(new SendControlsPolicyData { DisableSend = true, DisableHideEmail = false });
+ policies[1].SetDataModel(new SendControlsPolicyData { DisableSend = false, DisableHideEmail = false });
+
+ var actual = sutProvider.Sut.Create(policies);
+
+ Assert.True(actual.DisableSend);
+ }
+
+ [Theory, BitAutoData]
+ public void DisableHideEmail_IsFalse_IfNoPolicies(SutProvider sutProvider)
+ {
+ var actual = sutProvider.Sut.Create([]);
+
+ Assert.False(actual.DisableHideEmail);
+ }
+
+ [Theory, BitAutoData]
+ public void DisableHideEmail_IsFalse_WhenNotConfigured(
+ [PolicyDetails(PolicyType.SendControls)] PolicyDetails[] policies,
+ SutProvider sutProvider)
+ {
+ foreach (var policy in policies)
+ {
+ policy.SetDataModel(new SendControlsPolicyData { DisableSend = false, DisableHideEmail = false });
+ }
+
+ var actual = sutProvider.Sut.Create(policies);
+
+ Assert.False(actual.DisableHideEmail);
+ }
+
+ [Theory, BitAutoData]
+ public void DisableHideEmail_IsTrue_IfAnyPolicyHasDisableHideEmail(
+ [PolicyDetails(PolicyType.SendControls)] PolicyDetails[] policies,
+ SutProvider sutProvider)
+ {
+ policies[0].SetDataModel(new SendControlsPolicyData { DisableSend = false, DisableHideEmail = true });
+ policies[1].SetDataModel(new SendControlsPolicyData { DisableSend = false, DisableHideEmail = false });
+
+ var actual = sutProvider.Sut.Create(policies);
+
+ Assert.True(actual.DisableHideEmail);
+ }
+
+ [Theory, BitAutoData]
+ public void BothFields_AreOrAggregatedAcrossMultiplePolicies(
+ [PolicyDetails(PolicyType.SendControls)] PolicyDetails[] policies,
+ SutProvider sutProvider)
+ {
+ policies[0].SetDataModel(new SendControlsPolicyData { DisableSend = true, DisableHideEmail = false });
+ policies[1].SetDataModel(new SendControlsPolicyData { DisableSend = false, DisableHideEmail = true });
+
+ var actual = sutProvider.Sut.Create(policies);
+
+ Assert.True(actual.DisableSend);
+ Assert.True(actual.DisableHideEmail);
+ }
+}
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/DisableSendSyncPolicyEventTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/DisableSendSyncPolicyEventTests.cs
new file mode 100644
index 000000000000..4b2af63eef0b
--- /dev/null
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/DisableSendSyncPolicyEventTests.cs
@@ -0,0 +1,136 @@
+#nullable enable
+
+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.PolicyValidators;
+using Bit.Core.AdminConsole.Repositories;
+using Bit.Core.Test.AdminConsole.AutoFixture;
+using Bit.Core.Utilities;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
+
+[SutProviderCustomize]
+public class DisableSendSyncPolicyEventTests
+{
+ [Theory, BitAutoData]
+ public async Task ExecutePostUpsertSideEffectAsync_CreatesNewSendControlsPolicy_WhenNoneExists(
+ [PolicyUpdate(PolicyType.DisableSend, enabled: true)] PolicyUpdate policyUpdate,
+ [Policy(PolicyType.DisableSend, enabled: true)] Policy postUpsertedPolicy,
+ SutProvider sutProvider)
+ {
+ postUpsertedPolicy.OrganizationId = policyUpdate.OrganizationId;
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendControls)
+ .Returns((Policy?)null);
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendOptions)
+ .Returns((Policy?)null);
+
+ await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(
+ new SavePolicyModel(policyUpdate), postUpsertedPolicy, null);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.OrganizationId == policyUpdate.OrganizationId &&
+ p.Type == PolicyType.SendControls &&
+ p.Enabled == true &&
+ (CoreHelpers.LoadClassFromJsonData(p.Data)!.DisableSend == true)));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ExecutePostUpsertSideEffectAsync_UpdatesExistingSendControlsPolicy(
+ [PolicyUpdate(PolicyType.DisableSend, enabled: false)] PolicyUpdate policyUpdate,
+ [Policy(PolicyType.DisableSend, enabled: false)] Policy postUpsertedPolicy,
+ [Policy(PolicyType.SendControls, enabled: true)] Policy existingSendControlsPolicy,
+ SutProvider sutProvider)
+ {
+ postUpsertedPolicy.OrganizationId = policyUpdate.OrganizationId;
+ existingSendControlsPolicy.OrganizationId = policyUpdate.OrganizationId;
+ existingSendControlsPolicy.SetDataModel(new SendControlsPolicyData { DisableSend = true, DisableHideEmail = false });
+
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendControls)
+ .Returns(existingSendControlsPolicy);
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendOptions)
+ .Returns((Policy?)null);
+
+ await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(
+ new SavePolicyModel(policyUpdate), postUpsertedPolicy, null);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.Id == existingSendControlsPolicy.Id &&
+ p.Enabled == false &&
+ (CoreHelpers.LoadClassFromJsonData(p.Data)!.DisableSend == false)));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ExecutePostUpsertSideEffectAsync_SendControlsEnabled_WhenEitherFieldIsTrue(
+ [PolicyUpdate(PolicyType.DisableSend, enabled: false)] PolicyUpdate policyUpdate,
+ [Policy(PolicyType.DisableSend, enabled: false)] Policy postUpsertedPolicy,
+ [Policy(PolicyType.SendControls, enabled: true)] Policy existingSendControlsPolicy,
+ [Policy(PolicyType.SendOptions, enabled: true)] Policy existingSendOptionsPolicy,
+ SutProvider sutProvider)
+ {
+ postUpsertedPolicy.OrganizationId = policyUpdate.OrganizationId;
+ existingSendControlsPolicy.OrganizationId = policyUpdate.OrganizationId;
+ existingSendOptionsPolicy.OrganizationId = policyUpdate.OrganizationId;
+ existingSendOptionsPolicy.SetDataModel(new SendOptionsPolicyData { DisableHideEmail = true });
+
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendControls)
+ .Returns(existingSendControlsPolicy);
+ // DisableSend is being turned off, but SendOptions is still enabled
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendOptions)
+ .Returns(existingSendOptionsPolicy);
+
+ await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(
+ new SavePolicyModel(policyUpdate), postUpsertedPolicy, null);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.Enabled == true)); // stays enabled because SendOptions is still enabled
+ }
+
+ [Theory, BitAutoData]
+ public async Task ExecutePostUpsertSideEffectAsync_SendControlsEnabled_WhenSendOptionsEnabled_AndSendControlsDidNotExist(
+ [PolicyUpdate(PolicyType.DisableSend, enabled: true)] PolicyUpdate policyUpdate,
+ [Policy(PolicyType.DisableSend, enabled: true)] Policy postUpsertedPolicy,
+ [Policy(PolicyType.SendOptions, enabled: true)] Policy existingSendOptionsPolicy,
+ SutProvider sutProvider)
+ {
+ postUpsertedPolicy.OrganizationId = policyUpdate.OrganizationId;
+ existingSendOptionsPolicy.OrganizationId = policyUpdate.OrganizationId;
+ existingSendOptionsPolicy.SetDataModel(new SendOptionsPolicyData { DisableHideEmail = true });
+
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendControls)
+ .Returns((Policy?)null);
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendOptions)
+ .Returns(existingSendOptionsPolicy);
+
+ await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(
+ new SavePolicyModel(policyUpdate), postUpsertedPolicy, null);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.OrganizationId == policyUpdate.OrganizationId &&
+ p.Type == PolicyType.SendControls &&
+ p.Enabled == true &&
+ CoreHelpers.LoadClassFromJsonData(p.Data)!.DisableSend == true &&
+ CoreHelpers.LoadClassFromJsonData(p.Data)!.DisableHideEmail == true));
+ }
+}
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendControlsSyncPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendControlsSyncPolicyValidatorTests.cs
new file mode 100644
index 000000000000..8d7d8cc086e8
--- /dev/null
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendControlsSyncPolicyValidatorTests.cs
@@ -0,0 +1,144 @@
+#nullable enable
+
+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.PolicyValidators;
+using Bit.Core.AdminConsole.Repositories;
+using Bit.Core.Test.AdminConsole.AutoFixture;
+using Bit.Core.Utilities;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
+
+[SutProviderCustomize]
+public class SendControlsSyncPolicyValidatorTests
+{
+ [Theory, BitAutoData]
+ public async Task ExecutePostUpsertSideEffectAsync_SyncsDisableSend_ToLegacyDisableSendPolicy(
+ [PolicyUpdate(PolicyType.SendControls, enabled: true)] PolicyUpdate policyUpdate,
+ [Policy(PolicyType.SendControls, enabled: true)] Policy postUpsertedPolicy,
+ SutProvider sutProvider)
+ {
+ postUpsertedPolicy.OrganizationId = policyUpdate.OrganizationId;
+ postUpsertedPolicy.SetDataModel(new SendControlsPolicyData { DisableSend = true, DisableHideEmail = false });
+
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendControls)
+ .Returns(postUpsertedPolicy);
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, Arg.Is(t => t != PolicyType.SendControls))
+ .Returns((Policy?)null);
+
+ await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(
+ new SavePolicyModel(policyUpdate), postUpsertedPolicy, null);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.OrganizationId == policyUpdate.OrganizationId &&
+ p.Type == PolicyType.DisableSend &&
+ p.Enabled == true));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ExecutePostUpsertSideEffectAsync_SyncsDisableHideEmail_ToLegacySendOptionsPolicy(
+ [PolicyUpdate(PolicyType.SendControls, enabled: true)] PolicyUpdate policyUpdate,
+ [Policy(PolicyType.SendControls, enabled: true)] Policy postUpsertedPolicy,
+ SutProvider sutProvider)
+ {
+ postUpsertedPolicy.OrganizationId = policyUpdate.OrganizationId;
+ postUpsertedPolicy.SetDataModel(new SendControlsPolicyData { DisableSend = false, DisableHideEmail = true });
+
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendControls)
+ .Returns(postUpsertedPolicy);
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, Arg.Is(t => t != PolicyType.SendControls))
+ .Returns((Policy?)null);
+
+ await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(
+ new SavePolicyModel(policyUpdate), postUpsertedPolicy, null);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.OrganizationId == policyUpdate.OrganizationId &&
+ p.Type == PolicyType.SendOptions &&
+ p.Enabled == true &&
+ (CoreHelpers.LoadClassFromJsonData(p.Data)!.DisableHideEmail == true)));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ExecutePostUpsertSideEffectAsync_DisablesLegacyPolicies_WhenSendControlsPolicyDisabled(
+ [PolicyUpdate(PolicyType.SendControls, enabled: false)] PolicyUpdate policyUpdate,
+ [Policy(PolicyType.SendControls, enabled: false)] Policy postUpsertedPolicy,
+ SutProvider sutProvider)
+ {
+ postUpsertedPolicy.OrganizationId = policyUpdate.OrganizationId;
+ postUpsertedPolicy.SetDataModel(new SendControlsPolicyData { DisableSend = true, DisableHideEmail = true });
+
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendControls)
+ .Returns(postUpsertedPolicy);
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, Arg.Is(t => t != PolicyType.SendControls))
+ .Returns((Policy?)null);
+
+ await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(
+ new SavePolicyModel(policyUpdate), postUpsertedPolicy, null);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.Type == PolicyType.DisableSend &&
+ p.Enabled == false));
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.Type == PolicyType.SendOptions &&
+ p.Enabled == false));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ExecutePostUpsertSideEffectAsync_UpdatesExistingLegacyPolicies(
+ [PolicyUpdate(PolicyType.SendControls, enabled: true)] PolicyUpdate policyUpdate,
+ [Policy(PolicyType.SendControls, enabled: true)] Policy postUpsertedPolicy,
+ [Policy(PolicyType.DisableSend, enabled: false)] Policy existingDisableSendPolicy,
+ [Policy(PolicyType.SendOptions, enabled: false)] Policy existingSendOptionsPolicy,
+ SutProvider sutProvider)
+ {
+ postUpsertedPolicy.OrganizationId = policyUpdate.OrganizationId;
+ existingDisableSendPolicy.OrganizationId = policyUpdate.OrganizationId;
+ existingSendOptionsPolicy.OrganizationId = policyUpdate.OrganizationId;
+ postUpsertedPolicy.SetDataModel(new SendControlsPolicyData { DisableSend = true, DisableHideEmail = true });
+
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendControls)
+ .Returns(postUpsertedPolicy);
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.DisableSend)
+ .Returns(existingDisableSendPolicy);
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendOptions)
+ .Returns(existingSendOptionsPolicy);
+
+ await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(
+ new SavePolicyModel(policyUpdate), postUpsertedPolicy, null);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.Id == existingDisableSendPolicy.Id &&
+ p.Enabled == true));
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.Id == existingSendOptionsPolicy.Id &&
+ p.Enabled == true));
+ }
+}
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendOptionsSyncPolicyEventTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendOptionsSyncPolicyEventTests.cs
new file mode 100644
index 000000000000..f63cfdd68d01
--- /dev/null
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SendOptionsSyncPolicyEventTests.cs
@@ -0,0 +1,138 @@
+#nullable enable
+
+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.PolicyValidators;
+using Bit.Core.AdminConsole.Repositories;
+using Bit.Core.Test.AdminConsole.AutoFixture;
+using Bit.Core.Utilities;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
+
+[SutProviderCustomize]
+public class SendOptionsSyncPolicyEventTests
+{
+ [Theory, BitAutoData]
+ public async Task ExecutePostUpsertSideEffectAsync_CreatesNewSendControlsPolicy_WhenNoneExists(
+ [PolicyUpdate(PolicyType.SendOptions, enabled: true)] PolicyUpdate policyUpdate,
+ [Policy(PolicyType.SendOptions, enabled: true)] Policy postUpsertedPolicy,
+ SutProvider sutProvider)
+ {
+ postUpsertedPolicy.OrganizationId = policyUpdate.OrganizationId;
+ postUpsertedPolicy.SetDataModel(new SendOptionsPolicyData { DisableHideEmail = true });
+
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendControls)
+ .Returns((Policy?)null);
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.DisableSend)
+ .Returns((Policy?)null);
+
+ await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(
+ new SavePolicyModel(policyUpdate), postUpsertedPolicy, null);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.OrganizationId == policyUpdate.OrganizationId &&
+ p.Type == PolicyType.SendControls &&
+ p.Enabled == true &&
+ (CoreHelpers.LoadClassFromJsonData(p.Data)!.DisableHideEmail == true)));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ExecutePostUpsertSideEffectAsync_ClearsDisableHideEmail_WhenPolicyDisabled(
+ [PolicyUpdate(PolicyType.SendOptions, enabled: false)] PolicyUpdate policyUpdate,
+ [Policy(PolicyType.SendOptions, enabled: false)] Policy postUpsertedPolicy,
+ [Policy(PolicyType.SendControls, enabled: true)] Policy existingSendControlsPolicy,
+ SutProvider sutProvider)
+ {
+ postUpsertedPolicy.OrganizationId = policyUpdate.OrganizationId;
+ postUpsertedPolicy.SetDataModel(new SendOptionsPolicyData { DisableHideEmail = true });
+ existingSendControlsPolicy.OrganizationId = policyUpdate.OrganizationId;
+ existingSendControlsPolicy.SetDataModel(new SendControlsPolicyData { DisableSend = false, DisableHideEmail = true });
+
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendControls)
+ .Returns(existingSendControlsPolicy);
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.DisableSend)
+ .Returns((Policy?)null);
+
+ await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(
+ new SavePolicyModel(policyUpdate), postUpsertedPolicy, null);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.Enabled == false &&
+ (CoreHelpers.LoadClassFromJsonData(p.Data)!.DisableHideEmail == false)));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ExecutePostUpsertSideEffectAsync_UpdatesExistingSendControlsPolicy(
+ [PolicyUpdate(PolicyType.SendOptions, enabled: true)] PolicyUpdate policyUpdate,
+ [Policy(PolicyType.SendOptions, enabled: true)] Policy postUpsertedPolicy,
+ [Policy(PolicyType.SendControls, enabled: false)] Policy existingSendControlsPolicy,
+ SutProvider sutProvider)
+ {
+ postUpsertedPolicy.OrganizationId = policyUpdate.OrganizationId;
+ postUpsertedPolicy.SetDataModel(new SendOptionsPolicyData { DisableHideEmail = true });
+ existingSendControlsPolicy.OrganizationId = policyUpdate.OrganizationId;
+ existingSendControlsPolicy.SetDataModel(new SendControlsPolicyData { DisableSend = false, DisableHideEmail = false });
+
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendControls)
+ .Returns(existingSendControlsPolicy);
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.DisableSend)
+ .Returns((Policy?)null);
+
+ await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(
+ new SavePolicyModel(policyUpdate), postUpsertedPolicy, null);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.Id == existingSendControlsPolicy.Id &&
+ p.Enabled == true &&
+ (CoreHelpers.LoadClassFromJsonData(p.Data)!.DisableHideEmail == true)));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ExecutePostUpsertSideEffectAsync_SendControlsEnabled_WhenDisableSendEnabled_AndSendControlsDidNotExist(
+ [PolicyUpdate(PolicyType.SendOptions, enabled: true)] PolicyUpdate policyUpdate,
+ [Policy(PolicyType.SendOptions, enabled: true)] Policy postUpsertedPolicy,
+ [Policy(PolicyType.DisableSend, enabled: true)] Policy existingDisableSendPolicy,
+ SutProvider sutProvider)
+ {
+ postUpsertedPolicy.OrganizationId = policyUpdate.OrganizationId;
+ postUpsertedPolicy.SetDataModel(new SendOptionsPolicyData { DisableHideEmail = true });
+ existingDisableSendPolicy.OrganizationId = policyUpdate.OrganizationId;
+
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SendControls)
+ .Returns((Policy?)null);
+ sutProvider.GetDependency()
+ .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.DisableSend)
+ .Returns(existingDisableSendPolicy);
+
+ await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(
+ new SavePolicyModel(policyUpdate), postUpsertedPolicy, null);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(p =>
+ p.OrganizationId == policyUpdate.OrganizationId &&
+ p.Type == PolicyType.SendControls &&
+ p.Enabled == true &&
+ CoreHelpers.LoadClassFromJsonData(p.Data)!.DisableSend == true &&
+ CoreHelpers.LoadClassFromJsonData(p.Data)!.DisableHideEmail == true));
+ }
+}
diff --git a/test/Core.Test/Tools/Services/SendValidationServiceTests.cs b/test/Core.Test/Tools/Services/SendValidationServiceTests.cs
index cf721e9f6a2d..46da19966a35 100644
--- a/test/Core.Test/Tools/Services/SendValidationServiceTests.cs
+++ b/test/Core.Test/Tools/Services/SendValidationServiceTests.cs
@@ -179,4 +179,45 @@ public async Task ValidateUserCanSaveAsync_WhenPoliciesDoNotApply_Success(
// No exception implies success
await sutProvider.Sut.ValidateUserCanSaveAsync(userId, send);
}
+
+ [Theory, BitAutoData]
+ public async Task ValidateUserCanSaveAsync_WhenSendControlsFlagEnabled_ThrowsWhenDisableSendApplies(
+ SutProvider sutProvider, Send send, Guid userId)
+ {
+ send.HideEmail = false;
+ sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.SendControls).Returns(true);
+ sutProvider.GetDependency().GetAsync(userId)
+ .Returns(new SendControlsPolicyRequirement { DisableSend = true, DisableHideEmail = false });
+
+ var exception = await Assert.ThrowsAsync(() =>
+ sutProvider.Sut.ValidateUserCanSaveAsync(userId, send));
+ Assert.Contains("you are only able to delete an existing Send", exception.Message);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateUserCanSaveAsync_WhenSendControlsFlagEnabled_ThrowsWhenDisableHideEmailApplies(
+ SutProvider sutProvider, Send send, Guid userId)
+ {
+ send.HideEmail = true;
+ sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.SendControls).Returns(true);
+ sutProvider.GetDependency().GetAsync(userId)
+ .Returns(new SendControlsPolicyRequirement { DisableSend = false, DisableHideEmail = true });
+
+ var exception = await Assert.ThrowsAsync(() =>
+ sutProvider.Sut.ValidateUserCanSaveAsync(userId, send));
+ Assert.Contains("you are not allowed to hide your email address", exception.Message);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateUserCanSaveAsync_WhenSendControlsFlagEnabled_NoPolicyRestrictions_Success(
+ SutProvider sutProvider, Send send, Guid userId)
+ {
+ send.HideEmail = true;
+ sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.SendControls).Returns(true);
+ sutProvider.GetDependency().GetAsync(userId)
+ .Returns(new SendControlsPolicyRequirement { DisableSend = false, DisableHideEmail = false });
+
+ // No exception implies success
+ await sutProvider.Sut.ValidateUserCanSaveAsync(userId, send);
+ }
}
diff --git a/util/Migrator/DbScripts_transition/2026-02-28_00_CreateSendControlsPolicies.sql b/util/Migrator/DbScripts_transition/2026-02-28_00_CreateSendControlsPolicies.sql
new file mode 100644
index 000000000000..52ff719518bc
--- /dev/null
+++ b/util/Migrator/DbScripts_transition/2026-02-28_00_CreateSendControlsPolicies.sql
@@ -0,0 +1,77 @@
+-- Migrate existing DisableSend (6) and SendOptions (7) policies into new SendControls (20)
+-- EDD-compatible: only inserts new rows, never modifies existing data
+
+DECLARE @SendControlsType TINYINT = 20;
+DECLARE @DisableSendType TINYINT = 6;
+DECLARE @SendOptionsType TINYINT = 7;
+DECLARE @BatchSize INT = 2000;
+DECLARE @RowsAffected INT = 1;
+
+WHILE @RowsAffected > 0
+BEGIN
+ INSERT INTO [dbo].[Policy] (
+ [Id], [OrganizationId], [Type], [Enabled], [Data], [CreationDate], [RevisionDate]
+ )
+ SELECT TOP (@BatchSize)
+ NEWID(),
+ combined.OrganizationId,
+ @SendControlsType,
+ -- Policy is enabled if either old policy was enabled
+ CASE WHEN ISNULL(combined.DisableSendEnabled, 0) = 1
+ OR ISNULL(combined.SendOptionsEnabled, 0) = 1
+ THEN 1 ELSE 0 END,
+ -- 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'}',
+ GETUTCDATE(),
+ GETUTCDATE()
+ FROM (
+ 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
+
+ ) combined
+ -- Skip orgs that already have a SendControls row
+ WHERE NOT EXISTS (
+ SELECT 1 FROM [dbo].[Policy] sc
+ WHERE sc.OrganizationId = combined.OrganizationId
+ AND sc.Type = @SendControlsType
+ );
+
+ SET @RowsAffected = @@ROWCOUNT;
+END
diff --git a/util/MySqlMigrations/Migrations/20260306000000_CreateSendControlsPolicies.Designer.cs b/util/MySqlMigrations/Migrations/20260306000000_CreateSendControlsPolicies.Designer.cs
new file mode 100644
index 000000000000..3c9419eecbc4
--- /dev/null
+++ b/util/MySqlMigrations/Migrations/20260306000000_CreateSendControlsPolicies.Designer.cs
@@ -0,0 +1,3568 @@
+//
+using System;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Bit.MySqlMigrations.Migrations
+{
+ [DbContext(typeof(DatabaseContext))]
+ [Migration("20260228000000_CreateSendControlsPolicies")]
+ partial class CreateSendControlsPolicies
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b =>
+ {
+ b.Property("CipherId")
+ .HasColumnType("char(36)");
+
+ b.Property("CollectionId")
+ .HasColumnType("char(36)");
+
+ b.Property("CollectionName")
+ .HasColumnType("longtext");
+
+ b.Property("Email")
+ .HasColumnType("longtext");
+
+ b.Property("GroupId")
+ .HasColumnType("char(36)");
+
+ b.Property("GroupName")
+ .HasColumnType("longtext");
+
+ b.Property("HidePasswords")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Manage")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ReadOnly")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ResetPasswordKey")
+ .HasColumnType("longtext");
+
+ b.Property("TwoFactorProviders")
+ .HasColumnType("longtext");
+
+ b.Property("UserGuid")
+ .HasColumnType("char(36)");
+
+ b.Property("UserName")
+ .HasColumnType("longtext");
+
+ b.Property("UsesKeyConnector")
+ .HasColumnType("tinyint(1)");
+
+ b.ToTable("OrganizationMemberBaseDetails");
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AllowAdminAccessToAllCollectionItems")
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("BillingEmail")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("BusinessAddress1")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessAddress2")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessAddress3")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessCountry")
+ .HasMaxLength(2)
+ .HasColumnType("varchar(2)");
+
+ b.Property("BusinessName")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessTaxNumber")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Gateway")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("GatewayCustomerId")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("GatewaySubscriptionId")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Identifier")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("LicenseKey")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("LimitCollectionCreation")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LimitCollectionDeletion")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LimitItemDeletion")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("MaxAutoscaleSeats")
+ .HasColumnType("int");
+
+ b.Property("MaxAutoscaleSmSeats")
+ .HasColumnType("int");
+
+ b.Property("MaxAutoscaleSmServiceAccounts")
+ .HasColumnType("int");
+
+ b.Property("MaxCollections")
+ .HasColumnType("smallint");
+
+ b.Property("MaxStorageGb")
+ .HasColumnType("smallint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("OwnersNotifiedOfAutoscaling")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Plan")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("PlanType")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("PrivateKey")
+ .HasColumnType("longtext");
+
+ b.Property("PublicKey")
+ .HasColumnType("longtext");
+
+ b.Property("ReferenceData")
+ .HasColumnType("longtext");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Seats")
+ .HasColumnType("int");
+
+ b.Property("SelfHost")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("SmSeats")
+ .HasColumnType("int");
+
+ b.Property("SmServiceAccounts")
+ .HasColumnType("int");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Storage")
+ .HasColumnType("bigint");
+
+ b.Property("SyncSeats")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("TwoFactorProviders")
+ .HasColumnType("longtext");
+
+ b.Property("Use2fa")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseAdminSponsoredFamilies")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseApi")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseAutomaticUserConfirmation")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseCustomPermissions")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseDirectory")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseDisableSmAdsForUsers")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseEvents")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseGroups")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseKeyConnector")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseMyItems")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseOrganizationDomains")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePasswordManager")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePhishingBlocker")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePolicies")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseResetPassword")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseRiskInsights")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseScim")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseSecretsManager")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseSso")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseTotp")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsersGetPremium")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Id", "Enabled")
+ .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" });
+
+ b.ToTable("Organization", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Data")
+ .HasColumnType("longtext");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("OrganizationId", "Type")
+ .IsUnique()
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.ToTable("Policy", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("BillingEmail")
+ .HasColumnType("longtext");
+
+ b.Property("BillingPhone")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessAddress1")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessAddress2")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessAddress3")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessCountry")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessName")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessTaxNumber")
+ .HasColumnType("longtext");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DiscountId")
+ .HasColumnType("longtext");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Gateway")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("GatewayCustomerId")
+ .HasColumnType("longtext");
+
+ b.Property("GatewaySubscriptionId")
+ .HasColumnType("longtext");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("UseEvents")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Provider", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Key")
+ .HasColumnType("longtext");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("ProviderId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Settings")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.HasIndex("ProviderId");
+
+ b.ToTable("ProviderOrganization", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Email")
+ .HasColumnType("longtext");
+
+ b.Property("Key")
+ .HasColumnType("longtext");
+
+ b.Property("Permissions")
+ .HasColumnType("longtext");
+
+ b.Property("ProviderId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProviderId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ProviderUser", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AccessCode")
+ .HasMaxLength(25)
+ .HasColumnType("varchar(25)");
+
+ b.Property("Approved")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("AuthenticationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Key")
+ .HasColumnType("longtext");
+
+ b.Property("MasterPasswordHash")
+ .HasColumnType("longtext");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("PublicKey")
+ .HasColumnType("longtext");
+
+ b.Property("RequestCountryName")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("RequestDeviceIdentifier")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("RequestDeviceType")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property