From 31fe7b0e12e096acb9cac2fe9d2cd4e7335ea524 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Thu, 19 Feb 2026 12:10:28 -0500 Subject: [PATCH] [PM-26378] Auto confirm events (#7017) * implement auto confirm push notification * fix test * fix test * simplify LINQ * add event logging for auto confirm * fix test --- .../Controllers/OrganizationsController.cs | 13 +++ .../AdminConsole/Services/IEventService.cs | 1 + src/Core/Dirt/Enums/EventSystemUser.cs | 1 + src/Core/Dirt/Enums/EventType.cs | 4 + .../Services/Implementations/EventService.cs | 19 ++++ .../NoopImplementations/NoopEventService.cs | 5 + src/Events/Controllers/CollectController.cs | 2 + .../OrganizationsControllerTests.cs | 105 ++++++++++++++++++ .../Dirt/Services/EventServiceTests.cs | 20 ++++ .../Controllers/CollectControllerTests.cs | 76 +++++++++++++ 10 files changed, 246 insertions(+) diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 6415ef0815..ac5514b838 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -61,6 +61,7 @@ public class OrganizationsController : Controller private readonly IPricingClient _pricingClient; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; private readonly IOrganizationBillingService _organizationBillingService; + private readonly IEventService _eventService; private readonly IAutomaticUserConfirmationOrganizationPolicyComplianceValidator _automaticUserConfirmationOrganizationPolicyComplianceValidator; public OrganizationsController( @@ -88,6 +89,7 @@ public class OrganizationsController : Controller IPricingClient pricingClient, IResendOrganizationInviteCommand resendOrganizationInviteCommand, IOrganizationBillingService organizationBillingService, + IEventService eventService, IAutomaticUserConfirmationOrganizationPolicyComplianceValidator automaticUserConfirmationOrganizationPolicyComplianceValidator) { _organizationRepository = organizationRepository; @@ -114,6 +116,7 @@ public class OrganizationsController : Controller _pricingClient = pricingClient; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; _organizationBillingService = organizationBillingService; + _eventService = eventService; _automaticUserConfirmationOrganizationPolicyComplianceValidator = automaticUserConfirmationOrganizationPolicyComplianceValidator; } @@ -283,6 +286,8 @@ public class OrganizationsController : Controller } } + var previousUseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation; + UpdateOrganization(organization, model); var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); if (organization.UseSecretsManager && !plan.SupportsSecretsManager) @@ -304,6 +309,14 @@ public class OrganizationsController : Controller await _organizationRepository.ReplaceAsync(organization); + if (previousUseAutomaticUserConfirmation != organization.UseAutomaticUserConfirmation) + { + var eventType = organization.UseAutomaticUserConfirmation + ? EventType.Organization_AutoConfirmEnabled_Portal + : EventType.Organization_AutoConfirmDisabled_Portal; + await _eventService.LogOrganizationEventAsync(organization, eventType, EventSystemUser.BitwardenPortal); + } + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); // Sync name/email changes to Stripe diff --git a/src/Core/AdminConsole/Services/IEventService.cs b/src/Core/AdminConsole/Services/IEventService.cs index 795c06e254..7f7152eb32 100644 --- a/src/Core/AdminConsole/Services/IEventService.cs +++ b/src/Core/AdminConsole/Services/IEventService.cs @@ -28,6 +28,7 @@ public interface IEventService Task LogOrganizationUserEventsAsync(IEnumerable<(T, EventType, DateTime?)> events) where T : IOrganizationUser; Task LogOrganizationUserEventsAsync(IEnumerable<(T, EventType, EventSystemUser, DateTime?)> events) where T : IOrganizationUser; Task LogOrganizationEventAsync(Organization organization, EventType type, DateTime? date = null); + Task LogOrganizationEventAsync(Organization organization, EventType type, EventSystemUser systemUser, DateTime? date = null); Task LogProviderUserEventAsync(ProviderUser providerUser, EventType type, DateTime? date = null); Task LogProviderUsersEventAsync(IEnumerable<(ProviderUser, EventType, DateTime?)> events); Task LogProviderOrganizationEventAsync(ProviderOrganization providerOrganization, EventType type, DateTime? date = null); diff --git a/src/Core/Dirt/Enums/EventSystemUser.cs b/src/Core/Dirt/Enums/EventSystemUser.cs index 1eb1e5b4ab..fa37857515 100644 --- a/src/Core/Dirt/Enums/EventSystemUser.cs +++ b/src/Core/Dirt/Enums/EventSystemUser.cs @@ -7,4 +7,5 @@ public enum EventSystemUser : byte DomainVerification = 2, PublicApi = 3, TwoFactorDisabled = 4, + BitwardenPortal = 5, } diff --git a/src/Core/Dirt/Enums/EventType.cs b/src/Core/Dirt/Enums/EventType.cs index 61372fc4e0..874255cdc7 100644 --- a/src/Core/Dirt/Enums/EventType.cs +++ b/src/Core/Dirt/Enums/EventType.cs @@ -84,6 +84,10 @@ public enum EventType : int Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617, Organization_ItemOrganization_Accepted = 1618, Organization_ItemOrganization_Declined = 1619, + Organization_AutoConfirmEnabled_Admin = 1620, + Organization_AutoConfirmDisabled_Admin = 1621, + Organization_AutoConfirmEnabled_Portal = 1622, + Organization_AutoConfirmDisabled_Portal = 1623, Policy_Updated = 1700, diff --git a/src/Core/Dirt/Services/Implementations/EventService.cs b/src/Core/Dirt/Services/Implementations/EventService.cs index 77d481890e..5904046512 100644 --- a/src/Core/Dirt/Services/Implementations/EventService.cs +++ b/src/Core/Dirt/Services/Implementations/EventService.cs @@ -309,6 +309,25 @@ public class EventService : IEventService await _eventWriteService.CreateAsync(e); } + public async Task LogOrganizationEventAsync(Organization organization, EventType type, EventSystemUser systemUser, DateTime? date = null) + { + if (!organization.Enabled || !organization.UseEvents) + { + return; + } + + var EventMessage = new EventMessage + { + OrganizationId = organization.Id, + ProviderId = await GetProviderIdAsync(organization.Id), + Type = type, + SystemUser = systemUser, + Date = date.GetValueOrDefault(DateTime.UtcNow), + DeviceType = DeviceType.Server + }; + await _eventWriteService.CreateAsync(EventMessage); + } + public async Task LogProviderUserEventAsync(ProviderUser providerUser, EventType type, DateTime? date = null) { await LogProviderUsersEventAsync(new[] { (providerUser, type, date) }); diff --git a/src/Core/Dirt/Services/NoopImplementations/NoopEventService.cs b/src/Core/Dirt/Services/NoopImplementations/NoopEventService.cs index 6ecea7d234..f5a6be3b7b 100644 --- a/src/Core/Dirt/Services/NoopImplementations/NoopEventService.cs +++ b/src/Core/Dirt/Services/NoopImplementations/NoopEventService.cs @@ -57,6 +57,11 @@ public class NoopEventService : IEventService return Task.FromResult(0); } + public Task LogOrganizationEventAsync(Organization organization, EventType type, EventSystemUser systemUser, DateTime? date = null) + { + return Task.FromResult(0); + } + public Task LogProviderUserEventAsync(ProviderUser providerUser, EventType type, DateTime? date = null) { return Task.FromResult(0); diff --git a/src/Events/Controllers/CollectController.cs b/src/Events/Controllers/CollectController.cs index 3902522665..0e95fd057d 100644 --- a/src/Events/Controllers/CollectController.cs +++ b/src/Events/Controllers/CollectController.cs @@ -131,6 +131,8 @@ public class CollectController : Controller break; case EventType.Organization_ClientExportedVault: + case EventType.Organization_AutoConfirmEnabled_Admin: + case EventType.Organization_AutoConfirmDisabled_Admin: if (!eventModel.OrganizationId.HasValue) { continue; diff --git a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 7e56a28577..0290105815 100644 --- a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Providers.Services; using Bit.Core.Enums; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Http; @@ -464,5 +465,109 @@ public class OrganizationsControllerTests .IsOrganizationCompliantAsync(Arg.Any()); } + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_UseAutomaticUserConfirmation_EnabledByPortal_LogsEvent( + Organization organization, + SutProvider sutProvider) + { + var update = new OrganizationEditModel + { + PlanType = PlanType.TeamsMonthly, + UseAutomaticUserConfirmation = true + }; + + organization.UseAutomaticUserConfirmation = false; + organization.Enabled = true; + organization.UseEvents = true; + + sutProvider.GetDependency() + .UserHasPermission(Permission.Org_Plan_Edit) + .Returns(true); + + var organizationRepository = sutProvider.GetDependency(); + organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organization.Id); + sutProvider.GetDependency() + .IsOrganizationCompliantAsync(Arg.Any()) + .Returns(Valid(request)); + + _ = await sutProvider.Sut.Edit(organization.Id, update); + + await sutProvider.GetDependency().Received(1) + .LogOrganizationEventAsync( + Arg.Is(o => o.Id == organization.Id), + EventType.Organization_AutoConfirmEnabled_Portal, + EventSystemUser.BitwardenPortal); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_UseAutomaticUserConfirmation_DisabledByPortal_LogsEvent( + Organization organization, + SutProvider sutProvider) + { + var update = new OrganizationEditModel + { + PlanType = PlanType.TeamsMonthly, + UseAutomaticUserConfirmation = false + }; + + organization.UseAutomaticUserConfirmation = true; + organization.Enabled = true; + organization.UseEvents = true; + + sutProvider.GetDependency() + .UserHasPermission(Permission.Org_Plan_Edit) + .Returns(true); + + var organizationRepository = sutProvider.GetDependency(); + organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + + _ = await sutProvider.Sut.Edit(organization.Id, update); + + await sutProvider.GetDependency().Received(1) + .LogOrganizationEventAsync( + Arg.Is(o => o.Id == organization.Id), + EventType.Organization_AutoConfirmDisabled_Portal, + EventSystemUser.BitwardenPortal); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_UseAutomaticUserConfirmation_NoChange_DoesNotLogEvent( + Organization organization, + SutProvider sutProvider) + { + var update = new OrganizationEditModel + { + PlanType = PlanType.TeamsMonthly, + UseAutomaticUserConfirmation = true + }; + + organization.UseAutomaticUserConfirmation = true; + organization.Enabled = true; + organization.UseEvents = true; + + sutProvider.GetDependency() + .UserHasPermission(Permission.Org_Plan_Edit) + .Returns(true); + + var organizationRepository = sutProvider.GetDependency(); + organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + + _ = await sutProvider.Sut.Edit(organization.Id, update); + + await sutProvider.GetDependency().DidNotReceive() + .LogOrganizationEventAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + #endregion } diff --git a/test/Core.Test/Dirt/Services/EventServiceTests.cs b/test/Core.Test/Dirt/Services/EventServiceTests.cs index d064fce2ec..6901a6656d 100644 --- a/test/Core.Test/Dirt/Services/EventServiceTests.cs +++ b/test/Core.Test/Dirt/Services/EventServiceTests.cs @@ -116,6 +116,26 @@ public class EventServiceTests e.InstallationId == installationId)); } + [Theory, BitAutoData] + public async Task LogOrganizationEvent_WithEventSystemUser_LogsRequiredInfo(Organization organization, EventType eventType, + EventSystemUser eventSystemUser, DateTime date, Guid providerId, SutProvider sutProvider) + { + organization.Enabled = true; + organization.UseEvents = true; + + sutProvider.GetDependency().ProviderIdForOrg(Arg.Any()).Returns(providerId); + + await sutProvider.Sut.LogOrganizationEventAsync(organization, eventType, eventSystemUser, date); + + await sutProvider.GetDependency().Received(1).CreateAsync(Arg.Is(e => + e.OrganizationId == organization.Id && + e.Type == eventType && + e.SystemUser == eventSystemUser && + e.DeviceType == DeviceType.Server && + e.Date == date && + e.ProviderId == providerId)); + } + [Theory, BitAutoData] public async Task LogOrganizationUserEvent_LogsRequiredInfo(OrganizationUser orgUser, EventType eventType, DateTime date, Guid actingUserId, Guid providerId, string ipAddress, DeviceType deviceType, SutProvider sutProvider) diff --git a/test/Events.Test/Controllers/CollectControllerTests.cs b/test/Events.Test/Controllers/CollectControllerTests.cs index b6fa018623..3d8175a84e 100644 --- a/test/Events.Test/Controllers/CollectControllerTests.cs +++ b/test/Events.Test/Controllers/CollectControllerTests.cs @@ -743,4 +743,80 @@ public class CollectControllerTests Arg.Is>>(tuples => tuples.Count() == 50) ); } + + [Theory] + [BitAutoData(EventType.Organization_AutoConfirmEnabled_Admin)] + [BitAutoData(EventType.Organization_AutoConfirmDisabled_Admin)] + public async Task Post_OrganizationAutoConfirmAdmin_WithValidOrg_LogsOrgEvent( + EventType eventType, Guid userId, Guid orgId, Organization organization) + { + _currentContext.UserId.Returns(userId); + organization.Id = orgId; + _organizationRepository.GetByIdAsync(orgId).Returns(organization); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = eventType, + OrganizationId = orgId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _organizationRepository.Received(1).GetByIdAsync(orgId); + await _eventService.Received(1).LogOrganizationEventAsync(organization, eventType, eventDate); + } + + [Theory] + [BitAutoData(EventType.Organization_AutoConfirmEnabled_Admin)] + [BitAutoData(EventType.Organization_AutoConfirmDisabled_Admin)] + public async Task Post_OrganizationAutoConfirmAdmin_WithoutOrgId_SkipsEvent( + EventType eventType, Guid userId) + { + _currentContext.UserId.Returns(userId); + var events = new List + { + new EventModel + { + Type = eventType, + OrganizationId = null, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default); + await _eventService.DidNotReceiveWithAnyArgs().LogOrganizationEventAsync(default, default, default); + } + + [Theory] + [BitAutoData(EventType.Organization_AutoConfirmEnabled_Admin)] + [BitAutoData(EventType.Organization_AutoConfirmDisabled_Admin)] + public async Task Post_OrganizationAutoConfirmAdmin_WithNullOrg_SkipsEvent( + EventType eventType, Guid userId, Guid orgId) + { + _currentContext.UserId.Returns(userId); + _organizationRepository.GetByIdAsync(orgId).Returns((Organization)null); + var events = new List + { + new EventModel + { + Type = eventType, + OrganizationId = orgId, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _organizationRepository.Received(1).GetByIdAsync(orgId); + await _eventService.DidNotReceiveWithAnyArgs().LogOrganizationEventAsync(default, default, default); + } }